diff --git a/.melos/base.yaml b/.melos/base.yaml index f5eedef..d01b63e 100644 --- a/.melos/base.yaml +++ b/.melos/base.yaml @@ -3,6 +3,7 @@ repository: https://github.com/protevus/platform packages: - apps/** - packages/** + - sandbox/** - helpers/tools/** - examples/** diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..9f7dfc1 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,77 @@ +# Generated documentation +api/ +build/ +site/ +_site/ +.dart_tool/ +.pub-cache/ +.pub/ +doc/api/ + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*~ +.DS_Store +Thumbs.db + +# IDE files +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.settings/ +.project +.classpath + +# Build artifacts +*.html +*.pdf +*.epub +*.mobi +*.docx +*.doc +*.rtf + +# Local configuration +.env +.env.local +*.local +local.* + +# Documentation tools +node_modules/ +package-lock.json +yarn.lock +pubspec.lock + +# Generated diagrams +*.svg +*.png +*.jpg +*.jpeg +*.gif +!assets/*.svg +!assets/*.png +!assets/*.jpg +!assets/*.jpeg +!assets/*.gif + +# Generated markdown +*.generated.md +*.auto.md +*.temp.md + +# Coverage reports +coverage/ +.coverage/ +coverage.xml +*.lcov + +# Log files +*.log +log/ +logs/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..ebe3cc9 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,163 @@ +# Documentation Changelog + +All notable changes to framework documentation will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2024-01-17 + +### Added +- Core Documentation + * Created Getting Started Guide + * Created Laravel Compatibility Roadmap + * Created Foundation Integration Guide + * Created Testing Guide + * Created Package Integration Map + * Created README.md + * Created index.md + * Created CONTRIBUTING.md + * Created CHANGELOG.md + +- Core Architecture + * Created Core Architecture documentation + * Created Core Package Specification + +- Package Specifications + * Created Container Package Specification + * Created Contracts Package Specification + * Created Events Package Specification + * Created Pipeline Package Specification + * Created Support Package Specification + * Created Bus Package Specification + * Created Config Package Specification + * Created Filesystem Package Specification + * Created Model Package Specification + * Created Process Package Specification + * Created Queue Package Specification + * Created Route Package Specification + * Created Testing Package Specification + +- Gap Analyses + * Created Container Gap Analysis + * Created Events Gap Analysis + * Created Pipeline Gap Analysis + * Created Bus Gap Analysis + * Created Config Gap Analysis + * Created Filesystem Gap Analysis + * Created Model Gap Analysis + * Created Process Gap Analysis + * Created Queue Gap Analysis + * Created Route Gap Analysis + * Created Testing Gap Analysis + +- Integration Guides + * Created Container Feature Integration + * Created Container Migration Guide + +### Documentation Features +- Complete package specifications with no placeholders +- Comprehensive gap analyses for Laravel compatibility +- Detailed integration guides and examples +- Cross-referenced documentation +- Consistent style and formatting +- Development guidelines for each package +- Testing requirements and examples +- Performance considerations +- Security guidelines + +### Documentation Structure +- Organized by package +- Clear navigation +- Related documentation links +- Code examples +- Implementation status +- Development guidelines + +### Documentation Standards +- No placeholders or truncation +- Complete code examples +- Working cross-references +- Proper markdown formatting +- Technical accuracy + +### Contributing Guidelines +- Documentation standards +- Writing style guide +- Review process +- Development workflow +- Content organization + +## Types of Changes +- `Added` for new documentation +- `Changed` for changes in existing documentation +- `Deprecated` for soon-to-be removed documentation +- `Removed` for now removed documentation +- `Fixed` for any documentation fixes +- `Security` for documentation about security updates + +## Maintaining the Changelog + +### Format +Each version should: +1. List version number and date +2. Group changes by type +3. List changes with bullet points +4. Reference related issues/PRs +5. Credit contributors + +### Example +```markdown +## [1.1.0] - YYYY-MM-DD +### Added +- New documentation for feature X (#123) +- Guide for implementing Y (@contributor) + +### Changed +- Updated Z documentation for clarity (#456) +- Improved examples in W guide (@contributor) + +### Fixed +- Corrected code example in V doc (#789) +- Fixed broken links in U guide (@contributor) +``` + +### Version Numbers +- MAJOR version for significant documentation restructuring +- MINOR version for new documentation additions +- PATCH version for documentation fixes and updates + +## Unreleased Changes + +### To Be Added +- Package-specific changelogs +- Version-specific documentation +- Migration guides for each version +- API documentation +- Additional examples + +### To Be Updated +- Performance guidelines +- Security considerations +- Testing strategies +- Integration patterns +- Development workflows + +## Previous Versions + +No previous versions. This is the initial documentation release. + +## Contributors + +- Initial documentation team +- Package maintainers +- Documentation reviewers +- Community contributors + +## Questions? + +For questions about documentation changes: +1. Review CONTRIBUTING.md +2. Check existing documentation +3. Ask in pull request +4. Update changelog as needed diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..418a5ce --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,280 @@ +# Contributing to Framework Documentation + +## Overview + +This guide explains how to contribute to our framework documentation. We maintain comprehensive documentation covering package specifications, gap analyses, integration guides, and architectural documentation. + +## Documentation Structure + +### Core Documentation +1. [Getting Started Guide](getting_started.md) - Framework introduction and setup +2. [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) - Implementation timeline +3. [Foundation Integration Guide](foundation_integration_guide.md) - Integration patterns +4. [Testing Guide](testing_guide.md) - Testing approaches +5. [Package Integration Map](package_integration_map.md) - Package relationships + +### Core Architecture +1. [Core Architecture](core_architecture.md) - System design and patterns +2. [Core Package Specification](core_package_specification.md) - Core implementation + +### Package Documentation +Each package has: +1. Package Specification - Implementation details +2. Gap Analysis - Laravel compatibility gaps +3. Integration Guide - Package integration patterns +4. Development Guidelines - Implementation standards + +## Contribution Guidelines + +### 1. Documentation Standards + +#### File Naming +- Use lowercase with underscores +- End with .md extension +- Be descriptive and specific +- Examples: + * package_specification.md + * gap_analysis.md + * integration_guide.md + +#### File Structure +- Start with # Title +- Include Overview section +- Add Related Documentation links +- Use clear section headers +- Include code examples +- End with development guidelines + +#### Content Requirements +- No placeholders or truncation +- Complete code examples +- Clear cross-references +- Proper markdown formatting +- Comprehensive coverage + +### 2. Writing Style + +#### Technical Writing +- Be clear and concise +- Use active voice +- Write in present tense +- Focus on technical accuracy +- Include practical examples + +#### Code Examples +```dart +// Include complete, working examples +class Example { + final String name; + + Example(this.name); + + void demonstrate() { + print('Demonstrating: $name'); + } +} + +// Show usage +var example = Example('feature'); +example.demonstrate(); +``` + +#### Cross-References +- Use relative links +- Link to related docs +- Reference specific sections +- Example: + ```markdown + See [Container Integration](container_package_specification.md#integration) for details. + ``` + +### 3. Documentation Types + +#### Package Specification +- Implementation details +- API documentation +- Integration examples +- Testing guidelines +- Development workflow + +#### Gap Analysis +- Current implementation +- Laravel features +- Missing functionality +- Implementation plan +- Priority order + +#### Integration Guide +- Integration points +- Package dependencies +- Code examples +- Best practices +- Common patterns + +### 4. Review Process + +#### Before Submitting +1. Check content completeness +2. Verify code examples +3. Test all links +4. Run markdown linter +5. Review formatting + +#### Pull Request +1. Clear description +2. Reference related issues +3. List documentation changes +4. Include review checklist +5. Add relevant labels + +#### Review Checklist +- [ ] No placeholders or truncation +- [ ] Complete code examples +- [ ] Working cross-references +- [ ] Proper formatting +- [ ] Technical accuracy + +### 5. Development Workflow + +#### Creating Documentation +1. Create feature branch +2. Write documentation +3. Add code examples +4. Include cross-references +5. Submit pull request + +#### Updating Documentation +1. Review existing content +2. Make necessary changes +3. Update related docs +4. Verify all links +5. Submit pull request + +#### Review Process +1. Technical review +2. Style review +3. Code example review +4. Cross-reference check +5. Final approval + +## Style Guide + +### 1. Markdown + +#### Headers +```markdown +# Main Title +## Section Title +### Subsection Title +#### Minor Section +``` + +#### Lists +```markdown +1. Ordered Item +2. Ordered Item + - Unordered Sub-item + - Unordered Sub-item + +- Unordered Item +- Unordered Item + 1. Ordered Sub-item + 2. Ordered Sub-item +``` + +#### Code Blocks +```markdown +/// Code with syntax highlighting +```dart +class Example { + void method() { + // Implementation + } +} +``` + +/// Inline code +Use `var` for variable declaration. +``` + +#### Links +```markdown +[Link Text](relative/path/to/file.md) +[Link Text](relative/path/to/file.md#section) +``` + +### 2. Content Organization + +#### Package Documentation +1. Overview +2. Core Features +3. Integration Examples +4. Testing +5. Development Guidelines + +#### Gap Analysis +1. Overview +2. Missing Features +3. Implementation Gaps +4. Priority Order +5. Next Steps + +#### Integration Guide +1. Overview +2. Integration Points +3. Code Examples +4. Best Practices +5. Development Guidelines + +### 3. Code Examples + +#### Complete Examples +```dart +// Include all necessary imports +import 'package:framework/core.dart'; + +// Show complete implementation +class ServiceProvider { + final Container container; + + ServiceProvider(this.container); + + void register() { + container.singleton((c) => + ServiceImplementation() + ); + } +} + +// Demonstrate usage +void main() { + var container = Container(); + var provider = ServiceProvider(container); + provider.register(); +} +``` + +#### Integration Examples +```dart +// Show real integration scenarios +class UserService { + final EventDispatcher events; + final Database db; + + UserService(this.events, this.db); + + Future createUser(User user) async { + await events.dispatch(UserCreating(user)); + await db.users.insert(user); + await events.dispatch(UserCreated(user)); + } +} +``` + +## Questions? + +For questions or clarification: +1. Review existing documentation +2. Check style guide +3. Ask in pull request +4. Update guidelines as needed diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7e270e9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,271 @@ +# Framework Documentation + +## Overview + +This documentation covers our Dart framework implementation, including Laravel compatibility, package specifications, and architectural guides. The framework provides Laravel's powerful features and patterns while leveraging Dart's strengths. + +## Documentation Structure + +### Core Documentation +1. [Getting Started Guide](getting_started.md) - Framework introduction and setup +2. [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) - Implementation timeline +3. [Foundation Integration Guide](foundation_integration_guide.md) - Integration patterns +4. [Testing Guide](testing_guide.md) - Testing approaches and patterns +5. [Package Integration Map](package_integration_map.md) - Package relationships + +### Core Architecture +1. [Core Architecture](core_architecture.md) - System design and patterns + - Architectural decisions + - System patterns + - Extension points + - Package interactions + +### Package Documentation + +#### Core Framework +1. Core Package + - [Core Package Specification](core_package_specification.md) + - [Core Architecture](core_architecture.md) + +2. Container Package + - [Container Package Specification](container_package_specification.md) + - [Container Gap Analysis](container_gap_analysis.md) + - [Container Feature Integration](container_feature_integration.md) + - [Container Migration Guide](container_migration_guide.md) + +3. Contracts Package + - [Contracts Package Specification](contracts_package_specification.md) + +4. Events Package + - [Events Package Specification](events_package_specification.md) + - [Events Gap Analysis](events_gap_analysis.md) + +5. Pipeline Package + - [Pipeline Package Specification](pipeline_package_specification.md) + - [Pipeline Gap Analysis](pipeline_gap_analysis.md) + +6. Support Package + - [Support Package Specification](support_package_specification.md) + +#### Infrastructure +1. Bus Package + - [Bus Package Specification](bus_package_specification.md) + - [Bus Gap Analysis](bus_gap_analysis.md) + +2. Config Package + - [Config Package Specification](config_package_specification.md) + - [Config Gap Analysis](config_gap_analysis.md) + +3. Filesystem Package + - [Filesystem Package Specification](filesystem_package_specification.md) + - [Filesystem Gap Analysis](filesystem_gap_analysis.md) + +4. Model Package + - [Model Package Specification](model_package_specification.md) + - [Model Gap Analysis](model_gap_analysis.md) + +5. Process Package + - [Process Package Specification](process_package_specification.md) + - [Process Gap Analysis](process_gap_analysis.md) + +6. Queue Package + - [Queue Package Specification](queue_package_specification.md) + - [Queue Gap Analysis](queue_gap_analysis.md) + +7. Route Package + - [Route Package Specification](route_package_specification.md) + - [Route Gap Analysis](route_gap_analysis.md) + +8. Testing Package + - [Testing Package Specification](testing_package_specification.md) + - [Testing Gap Analysis](testing_gap_analysis.md) + +## Getting Started + +1. **Understanding the Framework** +```dart +// Start with these documents in order: +1. Getting Started Guide +2. Core Architecture +3. Laravel Compatibility Roadmap +4. Foundation Integration Guide +``` + +2. **Package Development** +```dart +// For each package: +1. Review package specification +2. Check gap analysis +3. Follow integration guide +4. Write tests +``` + +3. **Development Workflow** +```dart +// For each feature: +1. Review specifications +2. Write tests +3. Implement changes +4. Update documentation +``` + +## Key Concepts + +### 1. Service Container Architecture +```dart +// Core application setup +var container = Container(); +var app = Application(container) + ..environment = 'production' + ..basePath = Directory.current.path; + +await app.boot(); +``` + +### 2. Service Providers +```dart +class AppServiceProvider extends ServiceProvider { + @override + void register() { + // Register services + } + + @override + void boot() { + // Bootstrap services + } +} +``` + +### 3. Package Integration +```dart +// Cross-package usage +class UserService { + final EventDispatcher events; + final Queue queue; + + Future process(User user) async { + await events.dispatch(UserProcessing(user)); + await queue.push(ProcessUser(user)); + } +} +``` + +## Implementation Status + +### Core Framework (90%) +- Core Package (95%) + * Application lifecycle ✓ + * Service providers ✓ + * HTTP kernel ✓ + * Console kernel ✓ + * Exception handling ✓ + * Needs: Performance optimizations + +- Container Package (90%) + * Basic DI ✓ + * Auto-wiring ✓ + * Service providers ✓ + * Needs: Contextual binding + +### Infrastructure (80%) +- Bus Package (85%) + * Command dispatching ✓ + * Command queuing ✓ + * Needs: Command batching + +- Config Package (80%) + * Configuration repository ✓ + * Environment loading ✓ + * Needs: Config caching + +[Previous implementation status content remains exactly the same] + +## Contributing + +1. **Before Starting** +- Review relevant documentation +- Check implementation status +- Understand dependencies +- Write tests first + +2. **Development Process** +```dart +// 1. Create feature branch +git checkout -b feature/package-name/feature-name + +// 2. Write tests +void main() { + test('should implement feature', () { + // Test implementation + }); +} + +// 3. Implement feature +class Implementation { + // Feature code +} + +// 4. Submit PR +// - Include tests +// - Update documentation +// - Add examples +``` + +3. **Code Review** +- Verify specifications +- Check test coverage +- Review documentation +- Validate performance + +## Best Practices + +1. **API Design** +```dart +// Follow framework patterns +class Service { + // Match framework method signatures + Future handle(); + Future process(); +} +``` + +2. **Testing** +```dart +// Comprehensive test coverage +void main() { + group('Feature', () { + // Unit tests + // Integration tests + // Performance tests + // Error cases + }); +} +``` + +3. **Documentation** +```dart +/// Document framework compatibility +class Service { + /// Processes data following framework patterns. + /// + /// Example: + /// ```dart + /// var service = container.make(); + /// await service.process(); + /// ``` + Future process(); +} +``` + +## Questions? + +For questions or clarification: +1. Review relevant documentation +2. Check implementation examples +3. Consult team leads +4. Update documentation as needed + +## License + +This framework is open-sourced software licensed under the [MIT license](../LICENSE). diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 0000000..a13bc53 --- /dev/null +++ b/docs/assets/README.md @@ -0,0 +1,231 @@ +# Documentation Assets + +## Directory Structure + +``` +assets/ +├── diagrams/ # Architecture and flow diagrams +├── images/ # Screenshots and general images +├── logos/ # Framework and package logos +└── icons/ # UI and feature icons +``` + +## Asset Organization + +### 1. Diagrams +- Architecture diagrams +- Flow charts +- Sequence diagrams +- Component diagrams +- Class diagrams + +Example naming: +``` +diagrams/ +├── architecture/ +│ ├── system_overview.svg +│ ├── package_dependencies.svg +│ └── service_interaction.svg +├── flows/ +│ ├── request_lifecycle.svg +│ ├── event_handling.svg +│ └── queue_processing.svg +└── sequences/ + ├── authentication_flow.svg + ├── job_dispatch.svg + └── model_events.svg +``` + +### 2. Images +- Documentation screenshots +- Example outputs +- Visual guides +- Tutorial images + +Example naming: +``` +images/ +├── getting_started/ +│ ├── installation_step1.png +│ ├── configuration_step2.png +│ └── running_tests_step3.png +├── tutorials/ +│ ├── creating_service_provider.png +│ ├── setting_up_queue.png +│ └── configuring_cache.png +└── examples/ + ├── api_response.png + ├── console_output.png + └── test_results.png +``` + +### 3. Logos +- Framework logos +- Package logos +- Integration logos +- Partner logos + +Example naming: +``` +logos/ +├── framework/ +│ ├── full_logo.svg +│ ├── icon_only.svg +│ └── text_only.svg +├── packages/ +│ ├── container_logo.svg +│ ├── events_logo.svg +│ └── queue_logo.svg +└── partners/ + ├── vendor_logo.svg + ├── cloud_logo.svg + └── tools_logo.svg +``` + +### 4. Icons +- Feature icons +- UI elements +- Status indicators +- Action icons + +Example naming: +``` +icons/ +├── features/ +│ ├── caching.svg +│ ├── queuing.svg +│ └── routing.svg +├── status/ +│ ├── success.svg +│ ├── warning.svg +│ └── error.svg +└── actions/ + ├── configure.svg + ├── deploy.svg + └── monitor.svg +``` + +## Naming Conventions + +1. **File Names** + - Use lowercase + - Use underscores for spaces + - Include category prefix + - Include size/variant suffix + - Examples: + * diagram_system_overview_large.svg + * screenshot_installation_step1.png + * logo_framework_dark.svg + * icon_feature_cache_16px.svg + +2. **Directory Names** + - Use lowercase + - Use descriptive categories + - Group related assets + - Examples: + * diagrams/architecture/ + * images/tutorials/ + * logos/packages/ + * icons/features/ + +## File Formats + +1. **Diagrams** + - SVG (preferred for diagrams) + - PNG (when SVG not possible) + - Source files in separate repo + +2. **Images** + - PNG (preferred for screenshots) + - JPG (for photos) + - WebP (for web optimization) + +3. **Logos** + - SVG (preferred for logos) + - PNG (with multiple resolutions) + - Include source files + +4. **Icons** + - SVG (preferred for icons) + - PNG (with multiple sizes) + - Include source files + +## Usage Guidelines + +1. **Diagrams** + - Use consistent styling + - Include source files + - Maintain aspect ratios + - Use standard colors + +2. **Images** + - Optimize for web + - Use descriptive names + - Include alt text + - Maintain quality + +3. **Logos** + - Follow brand guidelines + - Include all variants + - Maintain proportions + - Use vector formats + +4. **Icons** + - Use consistent style + - Include multiple sizes + - Optimize for display + - Follow naming pattern + +## Contributing Assets + +1. **Adding New Assets** + - Follow naming conventions + - Use appropriate format + - Include source files + - Update documentation + +2. **Updating Assets** + - Maintain version history + - Update all variants + - Keep source files + - Document changes + +3. **Removing Assets** + - Update documentation + - Remove all variants + - Archive if needed + - Document removal + +## Best Practices + +1. **File Organization** + - Use correct directories + - Follow naming patterns + - Group related assets + - Maintain structure + +2. **Version Control** + - Commit source files + - Track large files properly + - Document changes + - Use git LFS if needed + +3. **Quality Control** + - Optimize for web + - Check resolutions + - Verify formats + - Test displays + +4. **Documentation** + - Reference assets properly + - Include alt text + - Document sources + - Credit creators + +## Questions? + +For questions about assets: +1. Check naming conventions +2. Review directory structure +3. Consult usage guidelines +4. Ask in pull request diff --git a/docs/assets/diagrams/architecture/README.md b/docs/assets/diagrams/architecture/README.md new file mode 100644 index 0000000..df0f933 --- /dev/null +++ b/docs/assets/diagrams/architecture/README.md @@ -0,0 +1,187 @@ +# Architecture Diagrams + +## System Overview + +The `system_overview.mmd` diagram shows the high-level architecture of our framework, including: + +1. Core System + - Application lifecycle + - Dependency injection (Container) + - Event handling + - Pipeline processing + +2. HTTP Layer + - Server handling + - HTTP kernel + - Routing + - Controllers + +3. Service Layer + - Configuration + - Caching + - Queue management + - Database operations + +4. Infrastructure + - Filesystem operations + - Process management + - Command bus + - Model layer + +5. Testing Integration + - Test cases + - HTTP testing + - Database testing + - Event testing + +## Rendering the Diagram + +### Using Mermaid CLI +```bash +# Install Mermaid CLI +npm install -g @mermaid-js/mermaid-cli + +# Generate SVG +mmdc -i system_overview.mmd -o system_overview.svg + +# Generate PNG +mmdc -i system_overview.mmd -o system_overview.png +``` + +### Using Online Tools +1. Visit [Mermaid Live Editor](https://mermaid.live) +2. Copy content of system_overview.mmd +3. Export as SVG or PNG + +### Using VSCode +1. Install "Markdown Preview Mermaid Support" extension +2. Open system_overview.mmd +3. Use preview to view diagram + +## Modifying the Diagram + +### Component Structure +```mermaid +%% Component Template +subgraph ComponentName ["Display Name"] + Node1[Node One] + Node2[Node Two] + + Node1 --> Node2 +end +``` + +### Style Definitions +```mermaid +%% Style Classes +classDef core fill:#f9f,stroke:#333,stroke-width:2px +classDef http fill:#bbf,stroke:#333,stroke-width:2px +classDef service fill:#bfb,stroke:#333,stroke-width:2px +classDef infra fill:#fbb,stroke:#333,stroke-width:2px +classDef test fill:#fff,stroke:#333,stroke-width:2px +``` + +### Adding Components +1. Define component in appropriate subgraph +2. Add relationships using arrows +3. Apply style class +4. Update documentation + +## Component Descriptions + +### Core System +- **Application**: Main entry point and lifecycle manager +- **Container**: Dependency injection container +- **Events**: Event dispatching and handling +- **Pipeline**: Request/process pipeline handling + +### HTTP Layer +- **Server**: HTTP server implementation +- **Kernel**: HTTP request kernel +- **Router**: Route matching and handling +- **Controller**: Request controllers + +### Service Layer +- **Config**: Configuration management +- **Cache**: Data caching +- **Queue**: Job queue management +- **Database**: Database operations + +### Infrastructure +- **FileSystem**: File operations +- **Process**: Process management +- **Bus**: Command bus implementation +- **Model**: Data model layer + +### Testing +- **TestCase**: Base test functionality +- **HttpTest**: HTTP testing utilities +- **DBTest**: Database testing utilities +- **EventTest**: Event testing utilities + +## Relationships + +### Core Dependencies +```mermaid +graph LR + App --> Container + App --> Events + App --> Pipeline +``` + +### Service Registration +```mermaid +graph LR + Container --> Services + Container --> Infrastructure +``` + +### Request Flow +```mermaid +graph LR + Server --> App + Controller --> Services + Services --> Infrastructure +``` + +### Event Flow +```mermaid +graph LR + Events --> Queue + Queue --> Process + Events --> Bus +``` + +## Best Practices + +1. **Adding Components** + - Place in appropriate subgraph + - Use consistent naming + - Add clear relationships + - Apply correct style + +2. **Updating Relationships** + - Keep lines clear + - Show direct dependencies + - Avoid crossing lines + - Group related flows + +3. **Maintaining Styles** + - Use defined classes + - Keep consistent colors + - Maintain line weights + - Use clear labels + +4. **Documentation** + - Update README.md + - Explain changes + - Document relationships + - Keep synchronized + +## Questions? + +For questions about architecture diagrams: +1. Check diagram documentation +2. Review Mermaid syntax +3. Consult team leads +4. Update documentation diff --git a/docs/assets/diagrams/architecture/system_overview.mmd b/docs/assets/diagrams/architecture/system_overview.mmd new file mode 100644 index 0000000..9765e8a --- /dev/null +++ b/docs/assets/diagrams/architecture/system_overview.mmd @@ -0,0 +1,105 @@ +graph TB + %% Core System + subgraph Core ["Core System"] + App[Application] + Container[Container] + Events[Events] + Pipeline[Pipeline] + + App --> Container + App --> Events + App --> Pipeline + end + + %% HTTP Layer + subgraph HTTP ["HTTP Layer"] + Server[HTTP Server] + Kernel[HTTP Kernel] + Router[Router] + Controller[Controller] + + Server --> Kernel + Kernel --> Router + Router --> Controller + end + + %% Service Layer + subgraph Services ["Service Layer"] + Config[Config] + Cache[Cache] + Queue[Queue] + DB[Database] + + Config --> Container + Cache --> Container + Queue --> Events + DB --> Events + end + + %% Infrastructure + subgraph Infrastructure ["Infrastructure"] + FileSystem[FileSystem] + Process[Process] + Bus[Command Bus] + Model[Model] + + FileSystem --> Container + Process --> Queue + Bus --> Queue + Model --> Events + end + + %% Request Flow + Server --> App + Controller --> Services + Services --> Infrastructure + + %% Event Flow + Events --> Queue + Queue --> Process + Events --> Bus + + %% Service Provider Registration + Container --> Services + Container --> Infrastructure + + %% Middleware Pipeline + Pipeline --> HTTP + Pipeline --> Services + + %% Testing Integration + subgraph Testing ["Testing"] + TestCase[TestCase] + HttpTest[HTTP Tests] + DBTest[DB Tests] + EventTest[Event Tests] + + TestCase --> App + HttpTest --> Server + DBTest --> DB + EventTest --> Events + end + + %% Style Definitions + classDef core fill:#f9f,stroke:#333,stroke-width:2px + classDef http fill:#bbf,stroke:#333,stroke-width:2px + classDef service fill:#bfb,stroke:#333,stroke-width:2px + classDef infra fill:#fbb,stroke:#333,stroke-width:2px + classDef test fill:#fff,stroke:#333,stroke-width:2px + + %% Apply Styles + class App,Container,Events,Pipeline core + class Server,Kernel,Router,Controller http + class Config,Cache,Queue,DB service + class FileSystem,Process,Bus,Model infra + class TestCase,HttpTest,DBTest,EventTest test + + %% Relationships + linkStyle default stroke:#333,stroke-width:2px + + %% Notes + %% Core System handles application lifecycle + %% HTTP Layer processes web requests + %% Service Layer provides business functionality + %% Infrastructure provides system services + %% Testing ensures system quality diff --git a/docs/assets/diagrams/flows/README.md b/docs/assets/diagrams/flows/README.md new file mode 100644 index 0000000..782058e --- /dev/null +++ b/docs/assets/diagrams/flows/README.md @@ -0,0 +1,625 @@ +# Flow Diagrams + +## Request Lifecycle + +The `request_lifecycle.mmd` diagram shows the complete lifecycle of an HTTP request through our framework, including: + +### 1. Entry Points +- Client HTTP Request +- Server Reception +- Kernel Handling + +### 2. Middleware Processing +1. **Global Middleware** + - Maintenance Mode Check + - Post Size Validation + - String Trimming + - Empty to Null Conversion + +2. **Route Middleware** + - Authentication + - Authorization + - Throttling + - CSRF Protection + +3. **Response Middleware** + - Session Handling + - Cookie Processing + - Header Management + - Response Compression + +### 3. Core Processing +1. **Route Resolution** + - Pattern Matching + - Parameter Binding + - Controller Resolution + +2. **Controller Handling** + - Action Execution + - Parameter Injection + - Response Generation + +### 4. Service Layer +1. **Business Logic** + - Service Processing + - Data Validation + - Business Rules + +2. **Data Operations** + - Database Queries + - Cache Access + - File Operations + +### 5. Event System +1. **Event Types** + - Model Events + - Custom Events + - System Events + +2. **Event Processing** + - Synchronous Events + - Queued Events + - Broadcast Events + +## Event Processing + +The `event_processing.mmd` diagram shows the complete lifecycle of events through our framework, including: + +### 1. Event Sources +- System Components +- User Actions +- External Triggers +- Scheduled Tasks + +### 2. Event Types +1. **Immediate Events** + - Synchronous Processing + - Direct Response + - In-Memory Handling + +2. **Queued Events** + - Asynchronous Processing + - Background Jobs + - Delayed Execution + +3. **Broadcast Events** + - Real-time Updates + - WebSocket Integration + - Channel Broadcasting + +### 3. Processing Components +1. **Event Dispatcher** + - Event Creation + - Type Detection + - Handler Resolution + - Event Routing + +2. **Queue System** + - Job Queuing + - Background Processing + - Retry Handling + - Failed Job Management + +3. **Broadcaster** + - Channel Management + - Real-time Delivery + - Client Connections + - Message Formatting + +4. **Event Listeners** + - Event Handling + - Business Logic + - Response Generation + - Error Handling + +### 4. Integration Points +1. **Database Operations** + - Transaction Management + - Data Persistence + - State Changes + - Audit Logging + +2. **Cache Operations** + - Cache Invalidation + - Cache Updates + - Performance Optimization + - State Management + +3. **Event Subscribers** + - Multiple Event Handling + - Event Grouping + - Subscriber Management + - Event Filtering + +### 5. Channel Types +1. **Public Channels** + - Open Access + - Public Events + - General Updates + +2. **Private Channels** + - Authentication Required + - User-Specific Events + - Secure Communication + +3. **Presence Channels** + - User Presence + - Online Status + - User Lists + - Real-time State + +## Queue Processing + +The `queue_processing.mmd` diagram shows the complete lifecycle of queued jobs through our framework, including: + +### 1. Job Sources +- Application Code +- Event Listeners +- Scheduled Tasks +- External Triggers + +### 2. Job States +1. **Pending** + - Initial State + - Awaiting Processing + - In Queue + +2. **Reserved** + - Worker Assigned + - Being Processed + - Locked for Processing + +3. **Released** + - Failed Attempt + - Ready for Retry + - Back in Queue + +4. **Failed** + - Max Retries Exceeded + - Permanent Failure + - Requires Attention + +### 3. Processing Components +1. **Queue Manager** + - Job Registration + - Queue Selection + - Job Scheduling + - State Management + +2. **Queue Worker** + - Job Processing + - Error Handling + - Retry Management + - Resource Cleanup + +3. **Job Handler** + - Business Logic + - Data Processing + - Event Dispatching + - Error Reporting + +4. **Failed Jobs** + - Failure Logging + - Retry Tracking + - Error Analysis + - Admin Notification + +### 4. Integration Points +1. **Database Operations** + - Job Storage + - State Tracking + - Transaction Management + - Failure Logging + +2. **Event System** + - Job Events + - Status Updates + - Error Notifications + - Progress Tracking + +3. **Queue Monitor** + - Health Checks + - Performance Metrics + - Worker Status + - Error Rates + +### 5. Processing Types +1. **Immediate Processing** + - Direct Execution + - No Delay + - Synchronous Option + +2. **Delayed Processing** + - Scheduled Execution + - Time-based Delays + - Future Processing + +3. **Batch Processing** + - Multiple Jobs + - Grouped Execution + - Bulk Operations + +4. **Chained Processing** + - Sequential Jobs + - Dependent Tasks + - Pipeline Processing + +### 6. Retry Strategy +1. **Exponential Backoff** + - Increasing Delays + - Retry Limits + - Failure Thresholds + +2. **Custom Delays** + - Job-specific Timing + - Conditional Delays + - Priority-based + +3. **Max Attempts** + - Attempt Limits + - Failure Handling + - Final State + +## Model Lifecycle + +The `model_lifecycle.mmd` diagram shows the complete lifecycle of models through our framework, including: + +### 1. Model Operations +1. **Creation** + - Instance Creation + - Event Dispatching + - Database Insertion + - Cache Management + +2. **Retrieval** + - Cache Checking + - Database Query + - Relationship Loading + - Model Hydration + +3. **Update** + - Change Tracking + - Event Dispatching + - Database Update + - Cache Invalidation + +4. **Deletion** + - Event Dispatching + - Database Deletion + - Cache Clearing + - Relationship Cleanup + +### 2. Event Integration +1. **Lifecycle Events** + - Creating/Created + - Updating/Updated + - Deleting/Deleted + - Retrieved/Saving + +2. **Relationship Events** + - Loading Relations + - Relation Loaded + - Relation Updated + - Relation Deleted + +3. **Cache Events** + - Cache Hit/Miss + - Cache Stored + - Cache Invalidated + - Cache Cleared + +### 3. Processing Components +1. **Model Instance** + - Attribute Management + - Change Tracking + - Event Dispatching + - State Management + +2. **Event System** + - Event Creation + - Observer Notification + - Queue Integration + - Event Broadcasting + +3. **Cache Layer** + - Cache Checking + - Cache Storage + - Cache Invalidation + - Cache Strategy + +4. **Database Layer** + - Query Execution + - Record Management + - Transaction Handling + - Relationship Loading + +### 4. Integration Points +1. **Observer System** + - Lifecycle Hooks + - Event Handling + - State Tracking + - Custom Logic + +2. **Cache Strategy** + - Read Through + - Write Behind + - Cache Invalidation + - Cache Tags + +3. **Queue Integration** + - Event Queueing + - Job Processing + - Async Operations + - Retry Handling + +### 5. Model States +1. **Pending** + - New Instance + - Not Persisted + - No Events + +2. **Active** + - Persisted + - Trackable + - Observable + +3. **Modified** + - Changes Tracked + - Events Pending + - Cache Invalid + +4. **Deleted** + - Soft Deleted + - Hard Deleted + - Cache Cleared + +### 6. Performance Features +1. **Eager Loading** + - Relationship Loading + - Query Optimization + - N+1 Prevention + +2. **Cache Management** + - Query Cache + - Model Cache + - Relationship Cache + +3. **Batch Operations** + - Bulk Insert + - Bulk Update + - Bulk Delete + +## Rendering the Diagrams + +### Using Mermaid CLI +```bash +# Install Mermaid CLI +npm install -g @mermaid-js/mermaid-cli + +# Generate SVG +mmdc -i request_lifecycle.mmd -o request_lifecycle.svg +mmdc -i event_processing.mmd -o event_processing.svg +mmdc -i queue_processing.mmd -o queue_processing.svg +mmdc -i model_lifecycle.mmd -o model_lifecycle.svg + +# Generate PNG +mmdc -i request_lifecycle.mmd -o request_lifecycle.png +mmdc -i event_processing.mmd -o event_processing.png +mmdc -i queue_processing.mmd -o queue_processing.png +mmdc -i model_lifecycle.mmd -o model_lifecycle.png +``` + +### Using Online Tools +1. Visit [Mermaid Live Editor](https://mermaid.live) +2. Copy content of .mmd files +3. Export as SVG or PNG + +### Using VSCode +1. Install "Markdown Preview Mermaid Support" extension +2. Open .mmd files +3. Use preview to view diagrams + +## Modifying the Diagrams + +### Sequence Structure +```mermaid +sequenceDiagram + participant A + participant B + + A->>B: Message + activate B + B-->>A: Response + deactivate B + + Note over A,B: Description +``` + +### Style Definitions +```mermaid +style Client fill:#f9f,stroke:#333,stroke-width:2px +style Server fill:#bbf,stroke:#333,stroke-width:2px +style Kernel fill:#bbf,stroke:#333,stroke-width:2px +style Pipeline fill:#bfb,stroke:#333,stroke-width:2px +``` + +### Adding Components +1. Add participant declaration +2. Add message sequences +3. Add activation/deactivation +4. Add notes and descriptions + +## Component Interactions + +### 1. Request Processing +```mermaid +sequenceDiagram + Client->>Server: Request + Server->>Kernel: Handle + Kernel->>Pipeline: Process +``` + +### 2. Middleware Chain +```mermaid +sequenceDiagram + Kernel->>Pipeline: Global Middleware + Pipeline->>Pipeline: Route Middleware + Pipeline->>Pipeline: Response Middleware +``` + +### 3. Business Logic +```mermaid +sequenceDiagram + Controller->>Services: Process + Services->>DB: Query + Services->>Events: Dispatch +``` + +### 4. Event Flow +```mermaid +sequenceDiagram + Source->>Dispatcher: Dispatch Event + Dispatcher->>Queue: Queue Event + Queue->>Listeners: Process Later +``` + +### 5. Broadcasting +```mermaid +sequenceDiagram + Dispatcher->>Broadcast: Send Event + Broadcast->>Clients: Real-time Update + Clients-->>Broadcast: Received +``` + +### 6. Queue Processing +```mermaid +sequenceDiagram + Source->>Queue: Dispatch Job + Queue->>Worker: Process Job + Worker->>Handler: Execute Job + Handler-->>Worker: Job Complete +``` + +### 7. Model Operations +```mermaid +sequenceDiagram + Client->>Model: Create/Update/Delete + Model->>Events: Dispatch Event + Events->>Observer: Handle Event + Model->>DB: Persist Changes + Model->>Cache: Update Cache +``` + +## Component Details + +### HTTP Layer +- **Server**: Handles raw HTTP requests +- **Kernel**: Manages request processing +- **Pipeline**: Executes middleware chain +- **Router**: Matches routes to controllers + +### Processing Layer +- **Controller**: Handles business logic +- **Services**: Processes domain logic +- **Events**: Manages system events +- **Database**: Handles data persistence + +### Event System +- **Dispatcher**: Central event hub +- **Queue**: Async processing +- **Broadcast**: Real-time updates +- **Listeners**: Event handlers + +### Queue System +- **Manager**: Queue management +- **Worker**: Job processing +- **Handler**: Job execution +- **Monitor**: Health checks + +### Model System +- **Model**: Data representation +- **Observer**: Event handling +- **Cache**: Performance layer +- **Relations**: Relationship management + +### Integration Points +- **Database**: Data persistence +- **Cache**: Performance layer +- **Subscribers**: Event consumers +- **External Systems**: Third-party services + +### Processing Types +- **Synchronous**: Immediate processing +- **Asynchronous**: Queued processing +- **Real-time**: Broadcast processing +- **Batch**: Grouped processing + +### Event Categories +- **System Events**: Framework operations +- **Domain Events**: Business logic +- **User Events**: User actions +- **Integration Events**: External systems + +## Best Practices + +### 1. Adding Sequences +- Use clear message names +- Show activation state +- Add relevant notes +- Group related actions + +### 2. Updating Flow +- Maintain sequence order +- Show parallel operations +- Indicate async processes +- Document timing + +### 3. Maintaining Style +- Use consistent colors +- Keep clear spacing +- Add helpful notes +- Use proper arrows + +### 4. Documentation +- Update README.md +- Explain changes +- Document new flows +- Keep synchronized + +### 5. Event Design +- Use clear event names +- Include necessary data +- Consider async needs +- Plan broadcast strategy + +### 6. Queue Management +- Set appropriate delays +- Handle failures +- Monitor queue size +- Implement retries + +### 7. Broadcast Strategy +- Choose correct channels +- Manage authentication +- Handle disconnections +- Optimize payload + +### 8. Error Handling +- Log failures +- Implement retries +- Notify administrators +- Maintain state + +### 9. Model Operations +- Track changes +- Manage cache +- Handle events +- Optimize queries + +## Questions? + +For questions about flow diagrams: +1. Check diagram documentation +2. Review Mermaid syntax +3. Consult team leads +4. Update documentation diff --git a/docs/assets/diagrams/flows/event_processing.mmd b/docs/assets/diagrams/flows/event_processing.mmd new file mode 100644 index 0000000..283916d --- /dev/null +++ b/docs/assets/diagrams/flows/event_processing.mmd @@ -0,0 +1,112 @@ +sequenceDiagram + participant Source as Event Source + participant Dispatcher as Event Dispatcher + participant Queue as Queue System + participant Broadcast as Broadcaster + participant Listeners as Event Listeners + participant DB as Database + participant Cache as Cache System + participant Subscribers as Event Subscribers + + %% Event Creation + Source->>Dispatcher: Dispatch Event + activate Dispatcher + Note over Dispatcher: Event Created + + %% Event Type Check + alt Is Queued Event + Dispatcher->>Queue: Push to Queue + activate Queue + Queue-->>Dispatcher: Queued Successfully + deactivate Queue + else Is Immediate Event + Dispatcher->>Listeners: Process Immediately + activate Listeners + end + + %% Broadcasting Check + alt Should Broadcast + Dispatcher->>Broadcast: Broadcast Event + activate Broadcast + Note over Broadcast: WebSocket/Redis/Pusher + Broadcast-->>Dispatcher: Broadcast Complete + deactivate Broadcast + end + + %% Database Operations + alt Has Database Operations + Dispatcher->>DB: Begin Transaction + activate DB + Note over DB: Event-related Changes + DB-->>Dispatcher: Transaction Complete + deactivate DB + end + + %% Cache Operations + alt Has Cache Operations + Dispatcher->>Cache: Update Cache + activate Cache + Note over Cache: Cache Invalidation/Update + Cache-->>Dispatcher: Cache Updated + deactivate Cache + end + + %% Event Subscribers + Dispatcher->>Subscribers: Notify Subscribers + activate Subscribers + Note over Subscribers: Handle Multiple Events + Subscribers-->>Dispatcher: Processing Complete + deactivate Subscribers + + %% Queued Event Processing + alt Is Queued Event + Queue->>Listeners: Process Queue + activate Queue + Note over Queue: Background Processing + Listeners-->>Queue: Processing Complete + deactivate Queue + end + + %% Event Listeners Processing + Note over Listeners: Process Event + Listeners-->>Dispatcher: Handling Complete + deactivate Listeners + + %% Event Completion + Dispatcher-->>Source: Event Processed + deactivate Dispatcher + + %% Style Definitions + style Source fill:#f9f,stroke:#333,stroke-width:2px + style Dispatcher fill:#bbf,stroke:#333,stroke-width:2px + style Queue fill:#bfb,stroke:#333,stroke-width:2px + style Broadcast fill:#fbb,stroke:#333,stroke-width:2px + style Listeners fill:#bfb,stroke:#333,stroke-width:2px + style DB fill:#fbb,stroke:#333,stroke-width:2px + style Cache fill:#fbb,stroke:#333,stroke-width:2px + style Subscribers fill:#bfb,stroke:#333,stroke-width:2px + + %% Notes + Note right of Source: System Component + Note right of Dispatcher: Event Management + Note right of Queue: Async Processing + Note right of Broadcast: Real-time Updates + Note right of Listeners: Event Handlers + Note right of DB: Persistence Layer + Note right of Cache: Performance Layer + Note right of Subscribers: Event Subscribers + + %% Event Types + Note over Dispatcher: Event Types:
1. Immediate Events
2. Queued Events
3. Broadcast Events + + %% Processing Types + Note over Listeners: Processing Types:
1. Sync Processing
2. Async Processing
3. Batch Processing + + %% Integration Points + Note over Queue: Integration:
1. Redis Queue
2. Database Queue
3. Memory Queue + + %% Broadcast Channels + Note over Broadcast: Channels:
1. Public Channels
2. Private Channels
3. Presence Channels + + %% Subscriber Types + Note over Subscribers: Subscriber Types:
1. Model Subscribers
2. System Subscribers
3. Custom Subscribers diff --git a/docs/assets/diagrams/flows/model_lifecycle.mmd b/docs/assets/diagrams/flows/model_lifecycle.mmd new file mode 100644 index 0000000..936ced3 --- /dev/null +++ b/docs/assets/diagrams/flows/model_lifecycle.mmd @@ -0,0 +1,161 @@ +sequenceDiagram + participant Client as Model Client + participant Model as Model Instance + participant Events as Event System + participant Cache as Cache Layer + participant DB as Database + participant Queue as Queue System + participant Relations as Relationship Loader + participant Observer as Model Observer + + %% Model Creation + Client->>Model: Create Model + activate Model + Model->>Events: Dispatch Creating Event + activate Events + Events->>Observer: Handle Creating + Observer-->>Events: Continue + Events-->>Model: Proceed + deactivate Events + + %% Database Operation + Model->>DB: Insert Record + activate DB + DB-->>Model: Record Created + deactivate DB + + %% Post Creation + Model->>Events: Dispatch Created Event + activate Events + Events->>Observer: Handle Created + Observer-->>Events: Continue + Events->>Queue: Queue Events + Queue-->>Events: Queued + Events-->>Model: Complete + deactivate Events + Model-->>Client: Model Instance + deactivate Model + + %% Model Retrieval + Client->>Model: Find Model + activate Model + Model->>Cache: Check Cache + activate Cache + + alt Cache Hit + Cache-->>Model: Return Cached + else Cache Miss + Cache-->>Model: Not Found + Model->>DB: Query Database + activate DB + DB-->>Model: Record Found + deactivate DB + Model->>Cache: Store in Cache + Cache-->>Model: Cached + end + deactivate Cache + + %% Relationship Loading + Model->>Relations: Load Relations + activate Relations + Relations->>DB: Query Relations + activate DB + DB-->>Relations: Related Records + deactivate DB + Relations-->>Model: Relations Loaded + deactivate Relations + Model-->>Client: Complete Model + deactivate Model + + %% Model Update + Client->>Model: Update Model + activate Model + Model->>Events: Dispatch Updating Event + activate Events + Events->>Observer: Handle Updating + Observer-->>Events: Continue + Events-->>Model: Proceed + deactivate Events + + Model->>DB: Update Record + activate DB + DB-->>Model: Record Updated + deactivate DB + + Model->>Cache: Invalidate Cache + activate Cache + Cache-->>Model: Cache Cleared + deactivate Cache + + Model->>Events: Dispatch Updated Event + activate Events + Events->>Observer: Handle Updated + Observer-->>Events: Continue + Events->>Queue: Queue Events + Queue-->>Events: Queued + Events-->>Model: Complete + deactivate Events + Model-->>Client: Updated Model + deactivate Model + + %% Model Deletion + Client->>Model: Delete Model + activate Model + Model->>Events: Dispatch Deleting Event + activate Events + Events->>Observer: Handle Deleting + Observer-->>Events: Continue + Events-->>Model: Proceed + deactivate Events + + Model->>DB: Delete Record + activate DB + DB-->>Model: Record Deleted + deactivate DB + + Model->>Cache: Clear Cache + activate Cache + Cache-->>Model: Cache Cleared + deactivate Cache + + Model->>Events: Dispatch Deleted Event + activate Events + Events->>Observer: Handle Deleted + Observer-->>Events: Continue + Events->>Queue: Queue Events + Queue-->>Events: Queued + Events-->>Model: Complete + deactivate Events + Model-->>Client: Deletion Confirmed + deactivate Model + + %% Style Definitions + style Client fill:#f9f,stroke:#333,stroke-width:2px + style Model fill:#bbf,stroke:#333,stroke-width:2px + style Events fill:#bfb,stroke:#333,stroke-width:2px + style Cache fill:#fbb,stroke:#333,stroke-width:2px + style DB fill:#fbb,stroke:#333,stroke-width:2px + style Queue fill:#bfb,stroke:#333,stroke-width:2px + style Relations fill:#bbf,stroke:#333,stroke-width:2px + style Observer fill:#bfb,stroke:#333,stroke-width:2px + + %% Notes + Note over Model: Model Lifecycle + Note over Events: Event Handling + Note over Cache: Cache Management + Note over DB: Data Persistence + Note over Queue: Async Processing + Note over Relations: Relationship Management + Note over Observer: Lifecycle Hooks + + %% Model States + Note over Model: States:
1. Creating/Created
2. Retrieving/Retrieved
3. Updating/Updated
4. Deleting/Deleted + + %% Event Types + Note over Events: Events:
1. Model Events
2. Relation Events
3. Cache Events
4. Queue Events + + %% Cache Strategy + Note over Cache: Strategy:
1. Read Through
2. Write Behind
3. Cache Invalidation + + %% Observer Points + Note over Observer: Hooks:
1. Before Events
2. After Events
3. Around Events diff --git a/docs/assets/diagrams/flows/queue_processing.mmd b/docs/assets/diagrams/flows/queue_processing.mmd new file mode 100644 index 0000000..1f7a1dd --- /dev/null +++ b/docs/assets/diagrams/flows/queue_processing.mmd @@ -0,0 +1,119 @@ +sequenceDiagram + participant Source as Job Source + participant Queue as Queue Manager + participant Worker as Queue Worker + participant Job as Job Handler + participant Events as Event System + participant DB as Database + participant Failed as Failed Jobs + participant Monitor as Queue Monitor + + %% Job Creation + Source->>Queue: Dispatch Job + activate Queue + Note over Queue: Create Job Record + + %% Job Configuration + alt Has Delay + Queue->>Queue: Schedule for Later + else No Delay + Queue->>Queue: Available Immediately + end + + %% Worker Pickup + Queue->>Worker: Worker Picks Up Job + activate Worker + Note over Worker: Start Processing + + %% Job Processing + Worker->>Job: Handle Job + activate Job + + %% Database Transaction + alt Has Database Operations + Job->>DB: Begin Transaction + activate DB + Note over DB: Perform Operations + DB-->>Job: Transaction Complete + deactivate DB + end + + %% Event Dispatching + alt Has Events + Job->>Events: Dispatch Events + activate Events + Note over Events: Process Events + Events-->>Job: Events Handled + deactivate Events + end + + %% Job Completion Check + alt Job Succeeds + Job-->>Worker: Processing Complete + Worker-->>Queue: Job Completed + Queue->>Events: JobProcessed Event + else Job Fails + Job-->>Worker: Throws Exception + + %% Retry Logic + alt Can Retry + Worker->>Queue: Release Job + Note over Queue: Increment Attempts + Queue-->>Worker: Job Released + else Max Retries Exceeded + Worker->>Failed: Move to Failed Jobs + activate Failed + Note over Failed: Log Failure + Failed-->>Worker: Failure Logged + deactivate Failed + end + end + + deactivate Job + deactivate Worker + deactivate Queue + + %% Queue Monitoring + Monitor->>Queue: Check Status + activate Monitor + Queue-->>Monitor: Queue Statistics + Note over Monitor: Monitor Queue Health + + Monitor->>Failed: Check Failed Jobs + Failed-->>Monitor: Failed Job Count + + Monitor->>Worker: Check Workers + Worker-->>Monitor: Worker Status + deactivate Monitor + + %% Style Definitions + style Source fill:#f9f,stroke:#333,stroke-width:2px + style Queue fill:#bbf,stroke:#333,stroke-width:2px + style Worker fill:#bfb,stroke:#333,stroke-width:2px + style Job fill:#bfb,stroke:#333,stroke-width:2px + style Events fill:#fbb,stroke:#333,stroke-width:2px + style DB fill:#fbb,stroke:#333,stroke-width:2px + style Failed fill:#fbb,stroke:#333,stroke-width:2px + style Monitor fill:#bbf,stroke:#333,stroke-width:2px + + %% Notes + Note right of Source: Application Code + Note right of Queue: Queue Management + Note right of Worker: Job Processing + Note right of Job: Business Logic + Note right of Events: Event System + Note right of DB: Data Layer + Note right of Failed: Error Handling + Note right of Monitor: Health Checks + + %% Job States + Note over Queue: Job States:
1. Pending
2. Reserved
3. Released
4. Failed + + %% Processing Types + Note over Worker: Processing:
1. Immediate
2. Delayed
3. Batched
4. Chained + + %% Retry Strategy + Note over Failed: Retry Strategy:
1. Exponential Backoff
2. Max Attempts
3. Custom Delays + + %% Monitoring Aspects + Note over Monitor: Monitoring:
1. Queue Size
2. Processing Rate
3. Error Rate
4. Worker Health diff --git a/docs/assets/diagrams/flows/request_lifecycle.mmd b/docs/assets/diagrams/flows/request_lifecycle.mmd new file mode 100644 index 0000000..f3b5004 --- /dev/null +++ b/docs/assets/diagrams/flows/request_lifecycle.mmd @@ -0,0 +1,98 @@ +sequenceDiagram + participant Client + participant Server as HTTP Server + participant Kernel as HTTP Kernel + participant Pipeline as Middleware Pipeline + participant Router + participant Controller + participant Services + participant Events + participant DB as Database + + %% Initial Request + Client->>Server: HTTP Request + activate Server + Server->>Kernel: Handle Request + activate Kernel + + %% Global Middleware + Kernel->>Pipeline: Process Global Middleware + activate Pipeline + Note over Pipeline: - Check Maintenance Mode
- Validate Post Size
- Trim Strings
- Convert Empty to Null + Pipeline-->>Kernel: Request Processed + deactivate Pipeline + + %% Route Matching + Kernel->>Router: Match Route + activate Router + Router-->>Kernel: Route Found + deactivate Router + + %% Route Middleware + Kernel->>Pipeline: Process Route Middleware + activate Pipeline + Note over Pipeline: - Authentication
- Authorization
- Throttling
- CSRF Protection + Pipeline-->>Kernel: Request Processed + deactivate Pipeline + + %% Controller Action + Kernel->>Controller: Handle Request + activate Controller + + %% Service Layer + Controller->>Services: Process Business Logic + activate Services + + %% Database Operations + Services->>DB: Query Data + activate DB + DB-->>Services: Data Retrieved + deactivate DB + + %% Event Dispatching + Services->>Events: Dispatch Events + activate Events + Note over Events: - Model Events
- Custom Events
- System Events + Events-->>Services: Events Processed + deactivate Events + + Services-->>Controller: Logic Processed + deactivate Services + + %% Response Generation + Controller-->>Kernel: Generate Response + deactivate Controller + + %% Response Middleware + Kernel->>Pipeline: Process Response Middleware + activate Pipeline + Note over Pipeline: - Session
- Cookies
- Headers
- Response Compression + Pipeline-->>Kernel: Response Processed + deactivate Pipeline + + %% Final Response + Kernel-->>Server: Return Response + deactivate Kernel + Server-->>Client: HTTP Response + deactivate Server + + %% Style Definitions + style Client fill:#f9f,stroke:#333,stroke-width:2px + style Server fill:#bbf,stroke:#333,stroke-width:2px + style Kernel fill:#bbf,stroke:#333,stroke-width:2px + style Pipeline fill:#bfb,stroke:#333,stroke-width:2px + style Router fill:#bfb,stroke:#333,stroke-width:2px + style Controller fill:#bfb,stroke:#333,stroke-width:2px + style Services fill:#fbb,stroke:#333,stroke-width:2px + style Events fill:#fbb,stroke:#333,stroke-width:2px + style DB fill:#fbb,stroke:#333,stroke-width:2px + + %% Notes + Note right of Server: Entry Point + Note right of Kernel: Request Processing + Note right of Pipeline: Middleware Chain + Note right of Router: Route Resolution + Note right of Controller: Business Logic + Note right of Services: Service Layer + Note right of Events: Event System + Note right of DB: Data Layer diff --git a/docs/bus_gap_analysis.md b/docs/bus_gap_analysis.md new file mode 100644 index 0000000..607913c --- /dev/null +++ b/docs/bus_gap_analysis.md @@ -0,0 +1,303 @@ +# Bus Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Bus package's actual implementation and Laravel's Bus functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Bus Package Specification](bus_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Job Chaining +class ChainedCommand { + // Need to implement: + void onConnection(String connection); + void onQueue(String queue); + void delay(Duration duration); + void middleware(List middleware); +} + +// 2. Rate Limiting +class RateLimitedCommand { + // Need to implement: + void rateLimit(int maxAttempts, Duration duration); + void rateLimitPerUser(int maxAttempts, Duration duration); + void withoutOverlapping(); +} + +// 3. Error Handling +class FailedCommandHandler { + // Need to implement: + Future failed(Command command, Exception exception); + Future retry(String commandId); + Future forget(String commandId); + Future flush(); +} +``` + +### 2. Existing Features Not Documented + +```dart +// Implemented but not documented: + +// 1. Command Mapping +class CommandMapper { + /// Maps command types to handlers + final Map _handlers = {}; + + /// Registers command handler + void map( + THandler Function() factory + ); +} + +// 2. Command Context +class CommandContext { + /// Command metadata + final Map _metadata = {}; + + /// Sets command context + void withContext(String key, dynamic value); + + /// Gets command context + T? getContext(String key); +} + +// 3. Command Lifecycle +class CommandLifecycle { + /// Command hooks + final List _beforeHandling = []; + final List _afterHandling = []; + + /// Registers lifecycle hooks + void beforeHandling(Function callback); + void afterHandling(Function callback); +} +``` + +### 3. Integration Points Not Documented + +```dart +// 1. Queue Integration +class QueuedCommand { + /// Queue configuration + String get connection => 'default'; + String get queue => 'default'; + Duration? get delay => null; + + /// Queue callbacks + void onQueue(QueueContract queue); + void onConnection(String connection); +} + +// 2. Event Integration +class EventedCommand { + /// Event dispatcher + final EventDispatcherContract _events; + + /// Dispatches command events + void dispatchCommandEvent(String event, dynamic data); + void subscribeCommandEvents(EventSubscriber subscriber); +} + +// 3. Cache Integration +class CachedCommand { + /// Cache configuration + String get cacheKey => ''; + Duration get cacheTTL => Duration(minutes: 60); + + /// Cache operations + Future cache(); + Future clearCache(); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation + +```dart +// Need to document: + +/// Maps command types to their handlers. +/// +/// Example: +/// ```dart +/// mapper.map( +/// () => CreateOrderHandler(repository) +/// ); +/// ``` +void map( + THandler Function() factory +); + +/// Sets command execution context. +/// +/// Example: +/// ```dart +/// command.withContext('user_id', userId); +/// command.withContext('tenant', tenant); +/// ``` +void withContext(String key, dynamic value); +``` + +### 2. Missing Integration Examples + +```dart +// Need examples for: + +// 1. Queue Integration +var command = CreateOrder(...) + ..onQueue('orders') + ..delay(Duration(minutes: 5)); + +await bus.dispatch(command); + +// 2. Event Integration +class OrderCommand extends EventedCommand { + void handle() { + // Handle command + dispatchCommandEvent('order.handled', order); + } +} + +// 3. Cache Integration +class GetOrderCommand extends CachedCommand { + @override + String get cacheKey => 'order.$orderId'; + + Future handle() async { + return await cache(() => repository.find(orderId)); + } +} +``` + +### 3. Missing Test Coverage + +```dart +// Need tests for: + +void main() { + group('Command Mapping', () { + test('maps commands to handlers', () { + var mapper = CommandMapper(); + mapper.map( + () => CreateOrderHandler(repository) + ); + + var handler = mapper.resolveHandler(CreateOrder()); + expect(handler, isA()); + }); + }); + + group('Command Context', () { + test('handles command context', () { + var command = TestCommand() + ..withContext('key', 'value'); + + expect(command.getContext('key'), equals('value')); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Command chaining (Laravel compatibility) + - Rate limiting (Laravel compatibility) + - Better error handling + +2. **Medium Priority** + - Command mapping + - Command context + - Performance optimizations + +3. **Low Priority** + - Additional helper methods + - Extended testing utilities + - Debug/profiling tools + +## Next Steps + +1. **Implementation Tasks** + - Add command chaining + - Add rate limiting + - Add error handling + - Improve queue integration + +2. **Documentation Tasks** + - Document command mapping + - Document command context + - Document command lifecycle + - Add integration examples + +3. **Testing Tasks** + - Add command mapping tests + - Add context tests + - Add lifecycle tests + - Add integration tests + +## Development Guidelines + +### 1. Getting Started +Before implementing bus features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Bus Package Specification](bus_package_specification.md) + +### 2. Implementation Process +For each bus feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Bus Package Specification](bus_package_specification.md) + +### 4. Integration Considerations +When implementing bus features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Bus system must: +1. Handle high command throughput +2. Process chains efficiently +3. Support async operations +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Bus tests must: +1. Cover all command scenarios +2. Test chaining behavior +3. Verify rate limiting +4. Check error handling +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Bus documentation must: +1. Explain command patterns +2. Show chaining examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/bus_package_specification.md b/docs/bus_package_specification.md new file mode 100644 index 0000000..8bc2435 --- /dev/null +++ b/docs/bus_package_specification.md @@ -0,0 +1,424 @@ +# Bus Package Specification + +## Overview + +The Bus package provides a robust command and event bus implementation that matches Laravel's bus functionality. It integrates with our Queue, Event, and Pipeline packages to provide a complete message bus solution with support for command handling, event dispatching, and middleware processing. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for event handling +> - See [Queue Package Specification](queue_package_specification.md) for queue integration + +## Core Features + +### 1. Command Bus + +```dart +/// Core command bus implementation +class CommandBus implements CommandBusContract { + final Container _container; + final QueueContract? _queue; + final PipelineContract _pipeline; + + CommandBus( + this._container, + this._pipeline, [ + this._queue + ]); + + /// Dispatches a command + Future dispatch(Command command) async { + if (command is ShouldQueue && _queue != null) { + return await dispatchToQueue(command); + } + + return await dispatchNow(command); + } + + /// Dispatches a command immediately + Future dispatchNow(Command command) async { + var handler = _resolveHandler(command); + + return await _pipeline + .send(command) + .through(_getPipes()) + .then((cmd) => handler.handle(cmd)); + } + + /// Dispatches a command to queue + Future dispatchToQueue(Command command) async { + await _queue!.push(QueuedCommandJob( + command: command, + handler: _resolveHandler(command) + )); + } + + /// Creates a command batch + PendingBatch batch(List commands) { + return PendingCommandBatch(this, commands); + } + + /// Creates a command chain + PendingChain chain(List commands) { + return PendingCommandChain(this, commands); + } + + /// Resolves command handler + Handler _resolveHandler(Command command) { + var handlerType = command.handler; + return _container.make(handlerType); + } + + /// Gets command pipes + List _getPipes() { + return [ + TransactionPipe(), + ValidationPipe(), + AuthorizationPipe() + ]; + } +} +``` + +### 2. Event Bus + +```dart +/// Core event bus implementation +class EventBus implements EventBusContract { + final Container _container; + final EventDispatcherContract _events; + final QueueContract? _queue; + + EventBus( + this._container, + this._events, [ + this._queue + ]); + + /// Dispatches an event + Future dispatch(Event event) async { + if (event is ShouldQueue && _queue != null) { + await dispatchToQueue(event); + } else { + await dispatchNow(event); + } + } + + /// Dispatches an event immediately + Future dispatchNow(Event event) async { + await _events.dispatch(event); + } + + /// Dispatches an event to queue + Future dispatchToQueue(Event event) async { + await _queue!.push(QueuedEventJob( + event: event, + listeners: _events.getListeners(event.runtimeType) + )); + } + + /// Registers an event listener + void listen(void Function(T event) listener) { + _events.listen(listener); + } + + /// Registers an event subscriber + void subscribe(EventSubscriber subscriber) { + _events.subscribe(subscriber); + } +} +``` + +### 3. Bus Middleware + +```dart +/// Transaction middleware +class TransactionPipe implements PipeContract { + final DatabaseManager _db; + + TransactionPipe(this._db); + + @override + Future handle(dynamic passable, Function next) async { + return await _db.transaction((tx) async { + return await next(passable); + }); + } +} + +/// Validation middleware +class ValidationPipe implements PipeContract { + final Validator _validator; + + ValidationPipe(this._validator); + + @override + Future handle(dynamic passable, Function next) async { + if (passable is ValidatableCommand) { + await _validator.validate( + passable.toMap(), + passable.rules() + ); + } + + return await next(passable); + } +} + +/// Authorization middleware +class AuthorizationPipe implements PipeContract { + final AuthManager _auth; + + AuthorizationPipe(this._auth); + + @override + Future handle(dynamic passable, Function next) async { + if (passable is AuthorizableCommand) { + if (!await passable.authorize(_auth)) { + throw UnauthorizedException(); + } + } + + return await next(passable); + } +} +``` + +### 4. Command Batching + +```dart +/// Pending command batch +class PendingCommandBatch implements PendingBatch { + final CommandBus _bus; + final List _commands; + bool _allowFailures = false; + + PendingCommandBatch(this._bus, this._commands); + + /// Allows failures in batch + PendingBatch allowFailures() { + _allowFailures = true; + return this; + } + + /// Dispatches the batch + Future dispatch() async { + for (var command in _commands) { + try { + await _bus.dispatchNow(command); + } catch (e) { + if (!_allowFailures) rethrow; + } + } + } +} + +/// Pending command chain +class PendingCommandChain implements PendingChain { + final CommandBus _bus; + final List _commands; + + PendingCommandChain(this._bus, this._commands); + + /// Dispatches the chain + Future dispatch() async { + dynamic result; + + for (var command in _commands) { + if (command is ChainedCommand) { + command.setPreviousResult(result); + } + + result = await _bus.dispatchNow(command); + } + } +} +``` + +## Integration Examples + +### 1. Command Bus Usage +```dart +// Define command +class CreateOrder implements Command { + final String customerId; + final List products; + + @override + Type get handler => CreateOrderHandler; +} + +// Define handler +class CreateOrderHandler implements Handler { + final OrderRepository _orders; + + CreateOrderHandler(this._orders); + + @override + Future handle(CreateOrder command) async { + return await _orders.create( + customerId: command.customerId, + products: command.products + ); + } +} + +// Dispatch command +var order = await bus.dispatch(CreateOrder( + customerId: '123', + products: ['abc', 'xyz'] +)); +``` + +### 2. Event Bus Usage +```dart +// Define event +class OrderCreated implements Event { + final Order order; + OrderCreated(this.order); +} + +// Register listener +eventBus.listen((event) async { + await notifyCustomer(event.order); +}); + +// Dispatch event +await eventBus.dispatch(OrderCreated(order)); +``` + +### 3. Command Batching +```dart +// Create batch +await bus.batch([ + CreateOrder(...), + UpdateInventory(...), + NotifyShipping(...) +]) +.allowFailures() +.dispatch(); + +// Create chain +await bus.chain([ + CreateOrder(...), + ProcessPayment(...), + ShipOrder(...) +]) +.dispatch(); +``` + +## Testing + +```dart +void main() { + group('Command Bus', () { + test('dispatches commands', () async { + var bus = CommandBus(container, pipeline); + var command = CreateOrder(...); + + await bus.dispatch(command); + + verify(() => handler.handle(command)).called(1); + }); + + test('handles command batch', () async { + var bus = CommandBus(container, pipeline); + + await bus.batch([ + CreateOrder(...), + UpdateInventory(...) + ]).dispatch(); + + verify(() => bus.dispatchNow(any())).called(2); + }); + }); + + group('Event Bus', () { + test('dispatches events', () async { + var bus = EventBus(container, dispatcher); + var event = OrderCreated(order); + + await bus.dispatch(event); + + verify(() => dispatcher.dispatch(event)).called(1); + }); + + test('queues events', () async { + var bus = EventBus(container, dispatcher, queue); + var event = OrderShipped(order); + + await bus.dispatch(event); + + verify(() => queue.push(any())).called(1); + }); + }); +} +``` + +## Next Steps + +1. Implement core bus features +2. Add middleware support +3. Add batching/chaining +4. Add queue integration +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing bus features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Events Package Specification](events_package_specification.md) +6. Review [Queue Package Specification](queue_package_specification.md) + +### 2. Implementation Process +For each bus feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in related packages + +### 4. Integration Considerations +When implementing bus features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Bus system must: +1. Handle high command throughput +2. Process events efficiently +3. Support async operations +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Bus tests must: +1. Cover all command scenarios +2. Test event handling +3. Verify queue integration +4. Check middleware behavior +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Bus documentation must: +1. Explain command patterns +2. Show event examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/config_gap_analysis.md b/docs/config_gap_analysis.md new file mode 100644 index 0000000..e1cc0a4 --- /dev/null +++ b/docs/config_gap_analysis.md @@ -0,0 +1,335 @@ +# Config Package Gap Analysis + +## Overview + +This document analyzes the gaps between our current configuration handling (in Core package) and Laravel's Config package functionality, identifying what needs to be implemented as a standalone Config package. + +> **Related Documentation** +> - See [Config Package Specification](config_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Package Structure +```dart +// Need to create dedicated Config package: + +packages/config/ +├── lib/ +│ ├── src/ +│ │ ├── config_repository.dart +│ │ ├── environment_loader.dart +│ │ ├── config_loader.dart +│ │ └── config_cache.dart +│ └── config.dart +├── test/ +└── example/ +``` + +### 2. Missing Core Features +```dart +// 1. Config Repository +class ConfigRepository { + // Need to implement: + T? get(String key, [T? defaultValue]); + void set(String key, dynamic value); + bool has(String key); + Map all(); + void merge(Map items); +} + +// 2. Environment Loading +class EnvironmentLoader { + // Need to implement: + Future load([String? path]); + String? get(String key, [String? defaultValue]); + void set(String key, String value); + bool has(String key); +} + +// 3. Configuration Loading +class ConfigurationLoader { + // Need to implement: + Future> load(); + Future> loadFile(String path); + Future reload(); +} +``` + +### 3. Missing Laravel Features +```dart +// 1. Package Configuration +class PackageConfig { + // Need to implement: + Future publish(String package, Map paths); + Future publishForce(String package, Map paths); + List publishedPackages(); +} + +// 2. Configuration Groups +class ConfigurationGroups { + // Need to implement: + void group(String name, List paths); + List getGroup(String name); + bool hasGroup(String name); +} + +// 3. Configuration Caching +class ConfigCache { + // Need to implement: + Future cache(Map config); + Future?> load(); + Future clear(); +} +``` + +## Integration Gaps + +### 1. Container Integration +```dart +// Need to implement: + +class ConfigServiceProvider { + void register() { + // Register config repository + container.singleton((c) => + ConfigRepository( + loader: c.make(), + cache: c.make() + ) + ); + + // Register environment loader + container.singleton((c) => + EnvironmentLoader( + path: c.make().base + ) + ); + } +} +``` + +### 2. Package Integration +```dart +// Need to implement: + +class PackageServiceProvider { + void register() { + // Register package config + publishConfig('my-package', { + 'config/my-package.php': 'my-package' + }); + } + + void boot() { + // Merge package config + config.merge({ + 'my-package': { + 'key': 'value' + } + }); + } +} +``` + +### 3. Environment Integration +```dart +// Need to implement: + +class EnvironmentServiceProvider { + void boot() { + var env = container.make(); + + // Load environment files + env.load(); + + if (env.get('APP_ENV') == 'local') { + env.load('.env.local'); + } + + // Set environment variables + config.set('app.env', env.get('APP_ENV', 'production')); + config.set('app.debug', env.get('APP_DEBUG', 'false') == 'true'); + } +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Manages application configuration. +/// +/// Provides access to configuration values using dot notation: +/// ```dart +/// var dbHost = config.get('database.connections.mysql.host'); +/// ``` +class ConfigRepository { + /// Gets a configuration value. + /// + /// Returns [defaultValue] if key not found. + T? get(String key, [T? defaultValue]); +} +``` + +### 2. Missing Usage Examples +```dart +// Need examples for: + +// 1. Basic Configuration +var appName = config.get('app.name', 'My App'); +var debug = config.get('app.debug', false); + +// 2. Environment Configuration +var dbConfig = { + 'host': env('DB_HOST', 'localhost'), + 'port': env('DB_PORT', '3306'), + 'database': env('DB_DATABASE'), + 'username': env('DB_USERNAME'), + 'password': env('DB_PASSWORD') +}; + +// 3. Package Configuration +class MyPackageServiceProvider { + void register() { + publishConfig('my-package', { + 'config/my-package.php': 'my-package' + }); + } +} +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Config Repository', () { + test('gets nested values', () { + var config = ConfigRepository({ + 'app': { + 'name': 'Test App', + 'nested': {'key': 'value'} + } + }); + + expect(config.get('app.name'), equals('Test App')); + expect(config.get('app.nested.key'), equals('value')); + }); + }); + + group('Environment Loader', () { + test('loads env files', () async { + var env = EnvironmentLoader(); + await env.load('.env.test'); + + expect(env.get('APP_NAME'), equals('Test App')); + expect(env.get('APP_ENV'), equals('testing')); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Create Config package structure + - Implement core repository + - Add environment loading + - Add configuration loading + +2. **Medium Priority** + - Add package configuration + - Add configuration groups + - Add configuration caching + - Add container integration + +3. **Low Priority** + - Add helper functions + - Add testing utilities + - Add debugging tools + +## Next Steps + +1. **Package Creation** + - Create package structure + - Move config code from Core + - Add package dependencies + - Setup testing + +2. **Core Implementation** + - Implement ConfigRepository + - Implement EnvironmentLoader + - Implement ConfigurationLoader + - Add caching support + +3. **Integration Implementation** + - Add container integration + - Add package support + - Add environment support + - Add service providers + +Would you like me to: +1. Create the Config package structure? +2. Start implementing core features? +3. Create detailed implementation plans? + +## Development Guidelines + +### 1. Getting Started +Before implementing config features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Config Package Specification](config_package_specification.md) + +### 2. Implementation Process +For each config feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Config Package Specification](config_package_specification.md) + +### 4. Integration Considerations +When implementing config features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Config system must: +1. Cache configuration efficiently +2. Minimize file I/O +3. Support lazy loading +4. Handle environment variables efficiently +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Config tests must: +1. Cover all configuration scenarios +2. Test environment handling +3. Verify caching behavior +4. Check file operations +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Config documentation must: +1. Explain configuration patterns +2. Show environment examples +3. Cover caching strategies +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/config_package_specification.md b/docs/config_package_specification.md new file mode 100644 index 0000000..3c9186a --- /dev/null +++ b/docs/config_package_specification.md @@ -0,0 +1,451 @@ +# Config Package Specification + +## Overview + +The Config package provides a flexible configuration management system that matches Laravel's config functionality. It integrates with our Container and Package systems while supporting hierarchical configuration, environment-based settings, and caching. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Contracts Package Specification](contracts_package_specification.md) for config contracts + +## Core Features + +### 1. Configuration Repository + +```dart +/// Core configuration repository +class ConfigRepository implements ConfigContract { + final Container _container; + final Map _items; + final EnvironmentLoader _env; + final ConfigCache? _cache; + + ConfigRepository( + this._container, [ + Map? items, + EnvironmentLoader? env, + ConfigCache? cache + ]) : _items = items ?? {}, + _env = env ?? EnvironmentLoader(), + _cache = cache; + + @override + T? get(String key, [T? defaultValue]) { + var value = _getNestedValue(key); + if (value == null) { + return defaultValue; + } + return _cast(value); + } + + @override + void set(String key, dynamic value) { + _setNestedValue(key, value); + _cache?.clear(); + } + + @override + bool has(String key) { + return _getNestedValue(key) != null; + } + + /// Gets all configuration items + Map all() => Map.from(_items); + + /// Merges configuration values + void merge(Map items) { + _items.addAll(_deepMerge(_items, items)); + _cache?.clear(); + } + + /// Deep merges two maps + Map _deepMerge( + Map target, + Map source + ) { + source.forEach((key, value) { + if (value is Map && target[key] is Map) { + target[key] = _deepMerge( + target[key] as Map, + value as Map + ); + } else { + target[key] = value; + } + }); + return target; + } + + /// Casts a value to the requested type + T _cast(dynamic value) { + if (value is T) return value; + + // Handle common type conversions + if (T == bool) { + if (value is String) { + return (value.toLowerCase() == 'true') as T; + } + return (value == 1) as T; + } + + if (T == int && value is String) { + return int.parse(value) as T; + } + + if (T == double && value is String) { + return double.parse(value) as T; + } + + throw ConfigCastException( + 'Cannot cast $value to $T' + ); + } +} +``` + +### 2. Environment Management + +```dart +/// Manages environment configuration +class EnvironmentManager { + final Container _container; + final Map _cache = {}; + final List _files = ['.env']; + + EnvironmentManager(this._container); + + /// Loads environment files + Future load([String? path]) async { + path ??= _container.make().base; + + for (var file in _files) { + var envFile = File('$path/$file'); + if (await envFile.exists()) { + var contents = await envFile.readAsString(); + _parseEnvFile(contents); + } + } + } + + /// Gets an environment variable + String? get(String key, [String? defaultValue]) { + return _cache[key] ?? + Platform.environment[key] ?? + defaultValue; + } + + /// Sets an environment variable + void set(String key, String value) { + _cache[key] = value; + } + + /// Adds an environment file + void addEnvFile(String file) { + _files.add(file); + } + + /// Parses an environment file + void _parseEnvFile(String contents) { + var lines = contents.split('\n'); + for (var line in lines) { + if (_isComment(line)) continue; + if (_isEmpty(line)) continue; + + var parts = line.split('='); + if (parts.length != 2) continue; + + var key = parts[0].trim(); + var value = _parseValue(parts[1].trim()); + + _cache[key] = value; + } + } + + /// Parses an environment value + String _parseValue(String value) { + // Remove quotes + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1); + } + + // Handle special values + switch (value.toLowerCase()) { + case 'true': + case '(true)': + return 'true'; + case 'false': + case '(false)': + return 'false'; + case 'empty': + case '(empty)': + return ''; + case 'null': + case '(null)': + return ''; + } + + return value; + } +} +``` + +### 3. Package Configuration + +```dart +/// Manages package configuration publishing +class ConfigPublisher { + final Container _container; + final Map> _publishGroups = {}; + + ConfigPublisher(this._container); + + /// Publishes configuration files + Future publish( + String package, + Map paths, [ + List? groups + ]) async { + var resolver = _container.make(); + var configPath = resolver.config; + + for (var entry in paths.entries) { + var source = entry.key; + var dest = '$configPath/${entry.value}'; + + await _publishFile(source, dest); + + if (groups != null) { + for (var group in groups) { + _publishGroups.putIfAbsent(group, () => []) + .add(dest); + } + } + } + } + + /// Gets files in a publish group + List getGroup(String name) { + return _publishGroups[name] ?? []; + } + + /// Copies a configuration file + Future _publishFile( + String source, + String destination + ) async { + var sourceFile = File(source); + var destFile = File(destination); + + if (!await destFile.exists()) { + await destFile.create(recursive: true); + await sourceFile.copy(destination); + } + } +} +``` + +### 4. Configuration Cache + +```dart +/// Caches configuration values +class ConfigCache { + final Container _container; + final String _cacheKey = 'config.cache'; + + ConfigCache(this._container); + + /// Caches configuration values + Future cache(Map items) async { + var cache = _container.make(); + await cache.forever(_cacheKey, items); + } + + /// Gets cached configuration + Future?> get() async { + var cache = _container.make(); + return await cache.get>(_cacheKey); + } + + /// Clears cached configuration + Future clear() async { + var cache = _container.make(); + await cache.forget(_cacheKey); + } +} +``` + +## Integration Examples + +### 1. Package Configuration +```dart +class MyPackageServiceProvider extends ServiceProvider { + @override + void register() { + // Publish package config + publishConfig('my-package', { + 'config/my-package.php': 'my-package' + }); + } + + @override + void boot() { + // Merge package config + var config = container.make(); + config.merge({ + 'my-package': { + 'key': 'value' + } + }); + } +} +``` + +### 2. Environment Configuration +```dart +class AppServiceProvider extends ServiceProvider { + @override + void boot() { + var env = container.make(); + + // Add environment files + env.addEnvFile('.env.local'); + if (protevusEnv.isTesting) { + env.addEnvFile('.env.testing'); + } + + // Load environment + await env.load(); + } +} +``` + +### 3. Configuration Cache +```dart +class CacheCommand { + Future handle() async { + var config = container.make(); + var cache = container.make(); + + // Cache config + await cache.cache(config.all()); + + // Clear config cache + await cache.clear(); + } +} +``` + +## Testing + +```dart +void main() { + group('Config Repository', () { + test('merges configuration', () { + var config = ConfigRepository(container); + + config.set('database', { + 'default': 'mysql', + 'connections': { + 'mysql': {'host': 'localhost'} + } + }); + + config.merge({ + 'database': { + 'connections': { + 'mysql': {'port': 3306} + } + } + }); + + expect( + config.get('database.connections.mysql'), + equals({ + 'host': 'localhost', + 'port': 3306 + }) + ); + }); + }); + + group('Environment Manager', () { + test('loads multiple env files', () async { + var env = EnvironmentManager(container); + env.addEnvFile('.env.testing'); + + await env.load(); + + expect(env.get('APP_ENV'), equals('testing')); + }); + }); +} +``` + +## Next Steps + +1. Complete package config publishing +2. Add config merging +3. Enhance environment handling +4. Add caching improvements +5. Write more tests + +Would you like me to enhance any other package specifications? + +## Development Guidelines + +### 1. Getting Started +Before implementing config features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Understand [Contracts Package Specification](contracts_package_specification.md) + +### 2. Implementation Process +For each config feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Implement required contracts (see [Contracts Package Specification](contracts_package_specification.md)) + +### 4. Integration Considerations +When implementing config features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) +5. Implement all contracts from [Contracts Package Specification](contracts_package_specification.md) + +### 5. Performance Guidelines +Config system must: +1. Cache configuration efficiently +2. Minimize file I/O +3. Support lazy loading +4. Handle environment variables efficiently +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Config tests must: +1. Cover all configuration scenarios +2. Test environment handling +3. Verify caching behavior +4. Check file operations +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Config documentation must: +1. Explain configuration patterns +2. Show environment examples +3. Cover caching strategies +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/container_feature_integration.md b/docs/container_feature_integration.md new file mode 100644 index 0000000..91c70c0 --- /dev/null +++ b/docs/container_feature_integration.md @@ -0,0 +1,379 @@ +# Container Feature Integration Guide + +## Overview + +This guide demonstrates how the Container's three major features work together to provide powerful dependency management: +1. Contextual Binding - Different implementations based on context +2. Method Injection - Automatic dependency resolution for methods +3. Tagged Bindings - Grouping related services + +## Real World Example: Multi-tenant Reporting System + +Let's build a complete multi-tenant reporting system that showcases all three features working together. + +### System Requirements + +1. Multiple tenants (clients) each need their own: + - Database connection + - Storage system + - Report formatting + +2. Various types of reports: + - Performance reports + - Financial reports + - User activity reports + +3. Each report needs: + - Data access + - Formatting + - Storage + - Logging + +### Base Interfaces + +```dart +/// Base interface for all reports +abstract class Report { + Future generate(); + Future save(); +} + +/// Database connection interface +abstract class Database { + Future>> query(String sql); +} + +/// Storage system interface +abstract class Storage { + Future save(String path, List data); + Future> load(String path); +} + +/// Report formatter interface +abstract class ReportFormatter { + String format(Map data); +} +``` + +### Tenant-Specific Implementations + +```dart +/// Tenant A's database implementation +class TenantADatabase implements Database { + @override + Future>> query(String sql) { + // Tenant A specific database logic + } +} + +/// Tenant B's database implementation +class TenantBDatabase implements Database { + @override + Future>> query(String sql) { + // Tenant B specific database logic + } +} + +/// Similar implementations for Storage and Formatter... +``` + +### Report Implementations + +```dart +class PerformanceReport implements Report { + final Database db; + final Storage storage; + final ReportFormatter formatter; + + PerformanceReport(this.db, this.storage, this.formatter); + + @override + Future generate() async { + var data = await db.query('SELECT * FROM performance_metrics'); + var formatted = formatter.format(data); + await storage.save('performance.report', formatted.codeUnits); + } +} + +// Similar implementations for Financial and UserActivity reports... +``` + +### Using All Three Features Together + +1. First, set up contextual bindings for tenant-specific services: + +```dart +void configureTenantA(Container container) { + // Bind tenant-specific implementations + container.when(TenantAContext) + .needs() + .give(TenantADatabase()); + + container.when(TenantAContext) + .needs() + .give(TenantAStorage()); + + container.when(TenantAContext) + .needs() + .give(TenantAFormatter()); +} + +void configureTenantB(Container container) { + // Similar bindings for Tenant B... +} +``` + +2. Set up tagged bindings for reports: + +```dart +void configureReports(Container container) { + // Bind report implementations + container.bind(PerformanceReport); + container.bind(FinancialReport); + container.bind(UserActivityReport); + + // Tag them for easy retrieval + container.tag([ + PerformanceReport, + FinancialReport, + UserActivityReport + ], 'reports'); + + // Additional tags for categorization + container.tag([PerformanceReport], 'metrics-reports'); + container.tag([FinancialReport], 'financial-reports'); +} +``` + +3. Create a report manager that uses method injection: + +```dart +class ReportManager { + final Container container; + + ReportManager(this.container); + + /// Generates all reports for a tenant + /// Uses method injection for the logger parameter + Future generateAllReports( + TenantContext tenant, + {required DateTime date} + ) async { + // Get all tagged reports + var reports = container.taggedAs('reports'); + + // Generate each report using tenant context + for (var report in reports) { + await container.call( + report, + 'generate', + parameters: {'date': date}, + context: tenant // Uses contextual binding + ); + } + } + + /// Generates specific report types + /// Uses method injection for dependencies + Future generateMetricsReports( + TenantContext tenant, + Logger logger, // Injected automatically + MetricsService metrics // Injected automatically + ) async { + var reports = container.taggedAs('metrics-reports'); + + for (var report in reports) { + logger.info('Generating metrics report: ${report.runtimeType}'); + await container.call(report, 'generate', context: tenant); + metrics.recordReportGeneration(report); + } + } +} +``` + +### Using the Integrated System + +```dart +void main() async { + var container = Container(); + + // Configure container + configureTenantA(container); + configureTenantB(container); + configureReports(container); + + // Create report manager + var manager = ReportManager(container); + + // Generate reports for Tenant A + await manager.generateAllReports( + TenantAContext(), + date: DateTime.now() + ); + + // Generate only metrics reports for Tenant B + await manager.generateMetricsReports( + TenantBContext() + ); +} +``` + +## How the Features Work Together + +1. **Contextual Binding** ensures: + - Each tenant gets their own implementations + - Services are properly scoped + - No cross-tenant data leakage + +2. **Method Injection** provides: + - Automatic dependency resolution + - Clean method signatures + - Flexible parameter handling + +3. **Tagged Bindings** enable: + - Easy service grouping + - Dynamic service discovery + - Flexible categorization + +## Common Integration Patterns + +1. **Service Location with Context** +```dart +// Get tenant-specific service +var db = container.make(context: tenantContext); + +// Get all services of a type for a tenant +var reports = container.taggedAs('reports') + .map((r) => container.make(r, context: tenantContext)) + .toList(); +``` + +2. **Method Injection with Tags** +```dart +Future processReports(Logger logger) async { + // Logger is injected, reports are retrieved by tag + var reports = container.taggedAs('reports'); + + for (var report in reports) { + logger.info('Processing ${report.runtimeType}'); + await container.call(report, 'process'); + } +} +``` + +3. **Contextual Services with Tags** +```dart +Future generateTenantReports(TenantContext tenant) async { + // Get all reports + var reports = container.taggedAs('reports'); + + // Process each with tenant context + for (var report in reports) { + await container.call( + report, + 'generate', + context: tenant + ); + } +} +``` + +## Best Practices + +1. **Clear Service Organization** +```dart +// Group related tags +container.tag([Service1, Service2], 'data-services'); +container.tag([Service1], 'cacheable-services'); + +// Group related contexts +container.when(TenantContext) + .needs() + .give(TenantDatabase()); +``` + +2. **Consistent Dependency Resolution** +```dart +// Prefer method injection for flexible dependencies +Future processReport( + Report report, + Logger logger, // Injected + MetricsService metrics // Injected +) async { + // Implementation +} + +// Use contextual binding for tenant-specific services +container.when(TenantContext) + .needs() + .give(TenantStorage()); +``` + +3. **Documentation** +```dart +/// Report processor that handles multiple report types +/// +/// Uses the following container features: +/// - Tagged bindings for report retrieval ('reports' tag) +/// - Contextual binding for tenant-specific services +/// - Method injection for logging and metrics +class ReportProcessor { + // Implementation +} +``` + +## Testing Integrated Features + +```dart +void main() { + group('Integrated Container Features', () { + late Container container; + + setUp(() { + container = Container(); + + // Set up test bindings + configureTenantA(container); + configureReports(container); + }); + + test('should handle tenant-specific tagged services', () { + var tenantA = TenantAContext(); + + // Get all reports for tenant + var reports = container.taggedAs('reports') + .map((r) => container.make(r, context: tenantA)) + .toList(); + + expect(reports, hasLength(3)); + expect(reports.every((r) => r.db is TenantADatabase), isTrue); + }); + + test('should inject dependencies with context', () async { + var processor = ReportProcessor(); + var tenantA = TenantAContext(); + + await container.call( + processor, + 'processReports', + context: tenantA + ); + + // Verify correct services were injected + verify(() => processor.logger is Logger).called(1); + verify(() => processor.db is TenantADatabase).called(1); + }); + }); +} +``` + +## Next Steps + +1. Implement integration tests +2. Add performance monitoring +3. Add dependency validation +4. Create usage documentation +5. Add debugging tools +6. Create migration guides + +Would you like me to create detailed specifications for any of these next steps? diff --git a/docs/container_gap_analysis.md b/docs/container_gap_analysis.md new file mode 100644 index 0000000..893eab0 --- /dev/null +++ b/docs/container_gap_analysis.md @@ -0,0 +1,261 @@ +# Container Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Container package's actual implementation and our documentation, identifying areas that need implementation or documentation updates. It also outlines the migration strategy to achieve full Laravel compatibility. + +> **Related Documentation** +> - See [Container Package Specification](container_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing requirements + +## Implementation Status + +> **Status Note**: This status aligns with our [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#implementation-status). See there for overall framework status. + +### 1. Core Features + +#### Implemented +```dart +✓ Basic dependency injection +✓ Service location +✓ Auto-wiring +✓ Parent/child containers +✓ Named singletons +✓ Lazy singleton registration +✓ Async dependency resolution +``` + +#### Partially Implemented +```dart +~ Contextual binding (basic structure) +~ Method injection (basic reflection) +~ Tagged bindings (basic tagging) +``` + +#### Not Implemented +```dart +- Advanced contextual binding features + * Instance-based context + * Multiple contexts + * Context inheritance + +- Advanced method injection features + * Parameter validation + * Optional parameters + * Named parameters + +- Advanced tagged binding features + * Tag inheritance + * Tag groups + * Tag conditions +``` + +## Migration Strategy + +> **Integration Note**: This migration strategy follows patterns from [Foundation Integration Guide](foundation_integration_guide.md). See there for detailed integration examples. + +### Phase 1: Internal Restructuring (No Breaking Changes) + +1. **Extract Binding Logic** +```dart +// Move from current implementation: +class Container { + final Map _bindings = {}; + void bind(T instance) => _bindings[T] = instance; +} + +// To new implementation: +class Container { + final Map _bindings = {}; + + void bind(T Function(Container) concrete) { + _bindings[T] = Binding( + concrete: concrete, + shared: false, + implementedType: T + ); + } +} +``` + +2. **Add Resolution Context** +```dart +class Container { + T make([dynamic context]) { + var resolutionContext = ResolutionContext( + resolvingType: T, + context: context, + container: this, + resolutionStack: {} + ); + + return _resolve(T, resolutionContext); + } +} +``` + +### Phase 2: Add New Features (Backward Compatible) + +1. **Contextual Binding** +```dart +class Container { + final Map> _contextualBindings = {}; + + ContextualBindingBuilder when(Type concrete) { + return ContextualBindingBuilder(this, concrete); + } +} +``` + +2. **Method Injection** +```dart +class Container { + dynamic call( + Object instance, + String methodName, [ + Map? parameters + ]) { + var method = reflector.reflectInstance(instance) + .type + .declarations[Symbol(methodName)]; + + var resolvedParams = _resolveMethodParameters( + method, + parameters + ); + + return Function.apply( + instance.runtimeType.getMethod(methodName), + resolvedParams + ); + } +} +``` + +3. **Tagged Bindings** +```dart +class Container { + final Map> _tags = {}; + + void tag(List types, String tag) { + _tags.putIfAbsent(tag, () => {}).addAll(types); + } + + List taggedAs(String tag) { + return _tags[tag]?.map((t) => make(t)).toList() ?? []; + } +} +``` + +### Phase 3: Performance Optimization + +> **Performance Note**: These optimizations align with our [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) performance targets. + +1. **Add Resolution Cache** +```dart +class Container { + final ResolutionCache _cache = ResolutionCache(); + + T make([dynamic context]) { + var cached = _cache.get(context); + if (cached != null) return cached; + + var instance = _resolve(T, context); + _cache.cache(instance, context); + return instance; + } +} +``` + +2. **Add Reflection Cache** +```dart +class Container { + final ReflectionCache _reflectionCache = ReflectionCache(); + + dynamic call(Object instance, String methodName, [Map? parameters]) { + var methodMirror = _reflectionCache.getMethod( + instance.runtimeType, + methodName + ) ?? _cacheMethod(instance, methodName); + + return _invokeMethod(instance, methodMirror, parameters); + } +} +``` + +## Backward Compatibility Requirements + +> **Note**: These requirements ensure compatibility while following [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) guidelines. + +1. **Maintain Existing APIs** +```dart +// These must continue to work: +container.make(); +container.makeAsync(); +container.has(); +container.hasNamed(); +container.registerFactory(); +container.registerSingleton(); +container.registerNamedSingleton(); +container.registerLazySingleton(); +``` + +2. **Preserve Behavior** +```dart +// Parent/child resolution +var parent = Container(reflector); +var child = parent.createChild(); +parent.registerSingleton(service); +var resolved = child.make(); // Must work + +// Named singletons +container.registerNamedSingleton('key', service); +var found = container.findByName('key'); // Must work + +// Async resolution +var future = container.makeAsync(); // Must work +``` + +## Implementation Priority + +> **Priority Note**: These priorities align with our [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#implementation-priorities). + +### 1. High Priority +- Complete contextual binding implementation +- Add parameter validation to method injection +- Implement tag inheritance + +### 2. Medium Priority +- Add cache integration +- Improve error handling +- Add event system integration + +### 3. Low Priority +- Add helper methods +- Add debugging tools +- Add performance monitoring + +## Development Guidelines + +### 1. Getting Started +Before implementing features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Container Package Specification](container_package_specification.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) + +### 2. Implementation Process +For each feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +## Next Steps + +Would you like me to: +1. Start implementing Phase 1 changes? +2. Create detailed specifications for Phase 2? +3. Design the caching system for Phase 3? diff --git a/docs/container_migration_guide.md b/docs/container_migration_guide.md new file mode 100644 index 0000000..c74c847 --- /dev/null +++ b/docs/container_migration_guide.md @@ -0,0 +1,452 @@ +# Container Migration Guide + +## Overview + +This guide helps you migrate from the current Container implementation to the new Laravel-compatible version. It covers: +1. Breaking changes +2. New features +3. Migration strategies +4. Code examples +5. Best practices + +## Breaking Changes + +### 1. Binding Registration + +#### Old Way +```dart +// Old implementation +container.bind(instance); +container.singleton(instance); +``` + +#### New Way +```dart +// New implementation +container.bind((c) => instance); +container.singleton((c) => instance); + +// With contextual binding +container.when(UserController) + .needs() + .give((c) => SpecialService()); +``` + +### 2. Service Resolution + +#### Old Way +```dart +// Old implementation +var service = container.make(); +var namedService = container.makeNamed('name'); +``` + +#### New Way +```dart +// New implementation +var service = container.make(); +var contextualService = container.make(context: UserController); +var taggedServices = container.taggedAs('tag'); +``` + +### 3. Method Injection + +#### Old Way +```dart +// Old implementation - manual parameter resolution +class UserService { + void process(User user) { + var logger = container.make(); + var validator = container.make(); + // Process user... + } +} +``` + +#### New Way +```dart +// New implementation - automatic method injection +class UserService { + void process( + User user, + Logger logger, // Automatically injected + Validator validator // Automatically injected + ) { + // Process user... + } +} + +// Usage +container.call(userService, 'process', {'user': user}); +``` + +## New Features + +### 1. Contextual Binding + +```dart +// Register different implementations based on context +void setupBindings(Container container) { + // Default storage + container.bind((c) => LocalStorage()); + + // User uploads use cloud storage + container.when(UserUploadController) + .needs() + .give((c) => CloudStorage()); + + // System files use local storage + container.when(SystemFileController) + .needs() + .give((c) => LocalStorage()); +} +``` + +### 2. Tagged Bindings + +```dart +// Register and tag related services +void setupReportServices(Container container) { + // Register services + container.bind((c) => PerformanceReport()); + container.bind((c) => FinancialReport()); + container.bind((c) => UserReport()); + + // Tag them for easy retrieval + container.tag([ + PerformanceReport, + FinancialReport, + UserReport + ], 'reports'); + + // Additional categorization + container.tag([PerformanceReport], 'metrics'); + container.tag([FinancialReport], 'financial'); +} + +// Usage +var reports = container.taggedAs('reports'); +var metricReports = container.taggedAs('metrics'); +``` + +### 3. Method Injection + +```dart +class ReportGenerator { + void generateReport( + Report report, + Logger logger, // Automatically injected + Formatter formatter, // Automatically injected + {required DateTime date} // Manually provided + ) { + logger.info('Generating report...'); + var data = report.getData(); + var formatted = formatter.format(data); + // Generate report... + } +} + +// Usage +container.call( + generator, + 'generateReport', + {'report': report, 'date': DateTime.now()} +); +``` + +## Migration Strategies + +### 1. Gradual Migration + +```dart +// Step 1: Update bindings +class ServiceRegistry { + void register(Container container) { + // Old way (still works) + container.bind(OldService()); + + // New way + container.bind((c) => NewService()); + + // Add contextual bindings + container.when(NewController) + .needs() + .give((c) => NewService()); + } +} + +// Step 2: Update resolution +class ServiceConsumer { + void process() { + // Old way (still works) + var oldService = container.make(); + + // New way + var newService = container.make(); + var contextual = container.make( + context: NewController + ); + } +} + +// Step 3: Add method injection +class ServiceProcessor { + // Old way + void processOld(Data data) { + var service = container.make(); + service.process(data); + } + + // New way + void processNew( + Data data, + Service service // Injected automatically + ) { + service.process(data); + } +} +``` + +### 2. Feature-by-Feature Migration + +1. **Update Bindings First** +```dart +// Update all bindings to new style +void registerBindings(Container container) { + // Update simple bindings + container.bind((c) => ServiceImpl()); + + // Add contextual bindings + container.when(Controller) + .needs() + .give((c) => SpecialService()); +} +``` + +2. **Add Tagged Services** +```dart +// Group related services +void registerServices(Container container) { + // Register services + container.bind((c) => ServiceA()); + container.bind((c) => ServiceB()); + + // Add tags + container.tag([ServiceA, ServiceB], 'services'); +} +``` + +3. **Implement Method Injection** +```dart +// Convert to method injection +class UserController { + // Before + void oldProcess(User user) { + var validator = container.make(); + var logger = container.make(); + // Process... + } + + // After + void newProcess( + User user, + Validator validator, + Logger logger + ) { + // Process... + } +} +``` + +## Testing During Migration + +### 1. Verify Bindings + +```dart +void main() { + group('Container Migration Tests', () { + late Container container; + + setUp(() { + container = Container(); + registerBindings(container); + }); + + test('should support old-style bindings', () { + var oldService = container.make(); + expect(oldService, isNotNull); + }); + + test('should support new-style bindings', () { + var newService = container.make(); + expect(newService, isNotNull); + }); + + test('should resolve contextual bindings', () { + var service = container.make( + context: Controller + ); + expect(service, isA()); + }); + }); +} +``` + +### 2. Verify Tagged Services + +```dart +void main() { + group('Tagged Services Tests', () { + late Container container; + + setUp(() { + container = Container(); + registerServices(container); + }); + + test('should resolve tagged services', () { + var services = container.taggedAs('services'); + expect(services, hasLength(2)); + expect(services, contains(isA())); + expect(services, contains(isA())); + }); + }); +} +``` + +### 3. Verify Method Injection + +```dart +void main() { + group('Method Injection Tests', () { + late Container container; + + setUp(() { + container = Container(); + registerServices(container); + }); + + test('should inject method dependencies', () { + var controller = UserController(); + + // Call with only required parameters + container.call( + controller, + 'newProcess', + {'user': testUser} + ); + + // Verify injection worked + verify(() => mockValidator.validate(any)).called(1); + verify(() => mockLogger.log(any)).called(1); + }); + }); +} +``` + +## Best Practices + +1. **Update Bindings Consistently** +```dart +// Good: Consistent new style +container.bind((c) => ServiceImpl()); +container.singleton((c) => FileLogger()); + +// Bad: Mixed styles +container.bind(ServiceImpl()); // Old style +container.singleton((c) => FileLogger()); // New style +``` + +2. **Use Contextual Bindings Appropriately** +```dart +// Good: Clear context and purpose +container.when(UserController) + .needs() + .give((c) => UserStorage()); + +// Bad: Unclear or overly broad context +container.when(Object) + .needs() + .give((c) => GenericStorage()); +``` + +3. **Organize Tagged Services** +```dart +// Good: Logical grouping +container.tag([ + PerformanceReport, + SystemReport +], 'system-reports'); + +container.tag([ + UserReport, + ActivityReport +], 'user-reports'); + +// Bad: Mixed concerns +container.tag([ + PerformanceReport, + UserReport, + Logger, + Storage +], 'services'); +``` + +## Common Issues and Solutions + +1. **Binding Resolution Errors** +```dart +// Problem: Missing binding +var service = container.make(); // Throws error + +// Solution: Register binding first +container.bind((c) => ServiceImpl()); +var service = container.make(); // Works +``` + +2. **Contextual Binding Conflicts** +```dart +// Problem: Multiple contexts +container.when(Controller) + .needs() + .give((c) => ServiceA()); + +container.when(Controller) // Conflict! + .needs() + .give((c) => ServiceB()); + +// Solution: Use specific contexts +container.when(UserController) + .needs() + .give((c) => ServiceA()); + +container.when(AdminController) + .needs() + .give((c) => ServiceB()); +``` + +3. **Method Injection Failures** +```dart +// Problem: Unresolvable parameter +void process( + CustomType param // Not registered with container +) { } + +// Solution: Register or provide parameter +container.bind((c) => CustomType()); +// or +container.call(instance, 'process', { + 'param': customInstance +}); +``` + +## Next Steps + +1. Audit existing container usage +2. Plan migration phases +3. Update bindings +4. Add new features +5. Update tests +6. Document changes + +Would you like me to create detailed specifications for any of these next steps? diff --git a/docs/container_package_specification.md b/docs/container_package_specification.md new file mode 100644 index 0000000..b3aae7c --- /dev/null +++ b/docs/container_package_specification.md @@ -0,0 +1,77 @@ +# Container Package Specification + +## Overview + +The Container package provides a powerful dependency injection container that matches Laravel's container functionality while leveraging Dart's type system. It supports auto-wiring, contextual binding, method injection, and tagged bindings. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Container Gap Analysis](container_gap_analysis.md) for missing features +> - See [Testing Guide](testing_guide.md) for testing approaches + +[Previous content remains the same until Core Features section, then add:] + +## Core Features + +> **Implementation Note**: These features are part of our Laravel compatibility effort. See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for the full feature list and status. + +[Previous features content remains the same, then add:] + +## Integration Examples + +> **Integration Note**: These examples demonstrate common integration patterns. See [Foundation Integration Guide](foundation_integration_guide.md) for more patterns and best practices. + +[Previous examples content remains the same, then add:] + +## Testing + +> **Testing Note**: These examples show package-specific tests. See [Testing Guide](testing_guide.md) for comprehensive testing approaches. + +[Previous testing content remains the same, then add:] + +## Performance Considerations + +> **Performance Note**: These optimizations align with our performance targets. See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) for specific metrics. + +[Previous performance content remains the same, then add:] + +## Development Guidelines + +### 1. Getting Started +Before contributing to this package: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Container Gap Analysis](container_gap_analysis.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) + +### 2. Integration Patterns +When integrating with other packages: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Check [Package Integration Map](package_integration_map.md) +3. Review [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) + +### 3. Testing Requirements +All contributions must: +1. Include unit tests (see [Testing Guide](testing_guide.md#unit-tests)) +2. Include integration tests (see [Testing Guide](testing_guide.md#integration-tests)) +3. Meet performance targets (see [Testing Guide](testing_guide.md#performance-tests)) + +### 4. Documentation Requirements +All changes must: +1. Update this specification if needed +2. Update [Container Gap Analysis](container_gap_analysis.md) if needed +3. Follow documentation standards in [Getting Started Guide](getting_started.md#documentation) + +## Next Steps + +See [Container Gap Analysis](container_gap_analysis.md) for: +1. Missing features to implement +2. Areas needing improvement +3. Integration gaps +4. Documentation gaps + +Would you like me to: +1. Add more integration examples? +2. Enhance testing documentation? +3. Add performance optimizations? diff --git a/docs/contracts_package_specification.md b/docs/contracts_package_specification.md new file mode 100644 index 0000000..2aec250 --- /dev/null +++ b/docs/contracts_package_specification.md @@ -0,0 +1,448 @@ +# Contracts Package Specification + +## Overview + +The Contracts package defines the core interfaces and contracts that form the foundation of the framework. These contracts ensure consistency and interoperability between components while enabling loose coupling and dependency injection. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Core Contracts + +### 1. Container Contracts + +```dart +/// Core container interface +abstract class ContainerContract { + /// Resolves a type from the container + T make([dynamic context]); + + /// Binds a type to the container + void bind(T Function(ContainerContract) concrete); + + /// Binds a singleton to the container + void singleton(T Function(ContainerContract) concrete); + + /// Checks if a type is bound + bool has(); + + /// Tags implementations for grouped resolution + void tag(List implementations, String tag); + + /// Gets all implementations with a tag + List tagged(String tag); + + /// Adds a contextual binding + void addContextualBinding(Type concrete, Type abstract, dynamic implementation); + + /// Creates a new child container + ContainerContract createChild(); +} + +/// Interface for contextual binding +abstract class ContextualBindingBuilder { + /// Specifies the type needed in this context + ContextualNeedsBuilder needs(); +} + +/// Interface for contextual needs +abstract class ContextualNeedsBuilder { + /// Specifies what to give for this need + void give(dynamic implementation); +} + +/// Interface for service providers +abstract class ServiceProviderContract { + /// Registers services with the container + void register(); + + /// Bootstraps any services + Future boot(); + + /// Gets provided services + List provides(); + + /// Whether provider is deferred + bool get isDeferred => false; +} +``` + +### 2. Event Contracts + +```dart +/// Core event dispatcher interface +abstract class EventDispatcherContract { + /// Registers an event listener + void listen(void Function(T event) listener); + + /// Dispatches an event + Future dispatch(T event); + + /// Registers an event subscriber + void subscribe(EventSubscriber subscriber); + + /// Dispatches an event after database commit + Future dispatchAfterCommit(T event); + + /// Gets registered listeners + List getListeners(Type event); +} + +/// Interface for event subscribers +abstract class EventSubscriber { + /// Gets events to subscribe to + Map subscribe(); +} + +/// Interface for queueable events +abstract class ShouldQueue { + /// Gets the queue to use + String get queue => 'default'; + + /// Gets the delay before processing + Duration? get delay => null; + + /// Gets the number of tries + int get tries => 1; +} + +/// Interface for broadcastable events +abstract class ShouldBroadcast { + /// Gets channels to broadcast on + List broadcastOn(); + + /// Gets event name for broadcasting + String broadcastAs() => runtimeType.toString(); + + /// Gets broadcast data + Map get broadcastWith => {}; +} +``` + +### 3. Queue Contracts + +```dart +/// Core queue interface +abstract class QueueContract { + /// Pushes a job onto the queue + Future push(dynamic job, [String? queue]); + + /// Pushes a job with delay + Future later(Duration delay, dynamic job, [String? queue]); + + /// Gets next job from queue + Future pop([String? queue]); + + /// Creates a job batch + Batch batch(List jobs); + + /// Gets a queue connection + QueueConnection connection([String? name]); +} + +/// Interface for queue jobs +abstract class Job { + /// Unique job ID + String get id; + + /// Job payload + Map get payload; + + /// Number of attempts + int get attempts; + + /// Maximum tries + int get tries => 1; + + /// Timeout in seconds + int get timeout => 60; + + /// Executes the job + Future handle(); + + /// Handles job failure + Future failed([Exception? exception]); +} + +/// Interface for job batches +abstract class Batch { + /// Batch ID + String get id; + + /// Jobs in batch + List get jobs; + + /// Adds jobs to batch + void add(List jobs); + + /// Dispatches the batch + Future dispatch(); +} +``` + +### 4. Bus Contracts + +```dart +/// Core command bus interface +abstract class CommandBusContract { + /// Dispatches a command + Future dispatch(Command command); + + /// Dispatches a command now + Future dispatchNow(Command command); + + /// Dispatches a command to queue + Future dispatchToQueue(Command command); + + /// Creates a command batch + PendingBatch batch(List commands); + + /// Creates a command chain + PendingChain chain(List commands); +} + +/// Interface for commands +abstract class Command { + /// Gets command handler + Type get handler; +} + +/// Interface for command handlers +abstract class Handler { + /// Handles the command + Future handle(T command); +} + +/// Interface for command batches +abstract class PendingBatch { + /// Dispatches the batch + Future dispatch(); + + /// Allows failures + PendingBatch allowFailures(); +} +``` + +### 5. Pipeline Contracts + +```dart +/// Core pipeline interface +abstract class PipelineContract { + /// Sends value through pipeline + PipelineContract send(T passable); + + /// Sets the pipes + PipelineContract through(List> pipes); + + /// Processes the pipeline + Future then(Future Function(T) destination); +} + +/// Interface for pipes +abstract class PipeContract { + /// Handles the passable + Future handle(T passable, Function next); +} + +/// Interface for pipeline hub +abstract class PipelineHubContract { + /// Gets a pipeline + PipelineContract pipeline(String name); + + /// Sets default pipes + void defaults(List pipes); +} +``` + +## Usage Examples + +### Container Usage +```dart +// Register service provider +class AppServiceProvider implements ServiceProviderContract { + @override + void register() { + container.bind((c) => UserService( + c.make(), + c.make() + )); + } + + @override + Future boot() async { + // Bootstrap services + } +} +``` + +### Event Handling +```dart +// Define event +class OrderShipped implements ShouldQueue, ShouldBroadcast { + final Order order; + + @override + List broadcastOn() => ['orders.${order.id}']; + + @override + String get queue => 'notifications'; +} + +// Handle event +dispatcher.listen((event) async { + await notifyCustomer(event.order); +}); +``` + +### Command Bus Usage +```dart +// Define command +class CreateOrder implements Command { + final String customerId; + final List products; + + @override + Type get handler => CreateOrderHandler; +} + +// Handle command +class CreateOrderHandler implements Handler { + @override + Future handle(CreateOrder command) async { + // Create order + } +} + +// Dispatch command +var order = await bus.dispatch(CreateOrder( + customerId: '123', + products: ['abc', 'xyz'] +)); +``` + +## Testing + +```dart +void main() { + group('Event Dispatcher', () { + test('dispatches after commit', () async { + var dispatcher = MockEventDispatcher(); + var db = MockDatabase(); + + await db.transaction((tx) async { + await dispatcher.dispatchAfterCommit(OrderShipped(order)); + }); + + verify(() => dispatcher.dispatch(any())).called(1); + }); + }); + + group('Command Bus', () { + test('handles command batch', () async { + var bus = MockCommandBus(); + + await bus.batch([ + CreateOrder(...), + UpdateInventory(...) + ]).dispatch(); + + verify(() => bus.dispatchNow(any())).called(2); + }); + }); +} +``` + +## Contract Guidelines + +1. **Keep Contracts Minimal** +```dart +// Good: Focused contract +abstract class Cache { + Future get(String key); + Future put(String key, T value); +} + +// Bad: Too many responsibilities +abstract class Cache { + Future get(String key); + Future put(String key, T value); + void clearMemory(); + void optimizeStorage(); + void defragment(); +} +``` + +2. **Use Type Parameters** +```dart +// Good: Type safe +abstract class Repository { + Future find(String id); + Future save(T entity); +} + +// Bad: Dynamic typing +abstract class Repository { + Future find(String id); + Future save(dynamic entity); +} +``` + +3. **Document Contracts** +```dart +/// Contract for caching implementations. +/// +/// Implementations must: +/// - Handle serialization +/// - Be thread-safe +/// - Support TTL +abstract class Cache { + /// Gets a value from cache. + /// + /// Returns null if not found. + /// Throws [CacheException] on error. + Future get(String key); +} +``` + +## Next Steps + +1. Implement core contracts +2. Add integration tests +3. Document Laravel compatibility +4. Add migration guides +5. Create examples +6. Write tests + +Would you like me to enhance any other package specifications? + +## Development Guidelines + +### 1. Getting Started +Before implementing contracts: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) + +### 2. Implementation Process +For each contract: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) + +### 4. Integration Considerations +When implementing contracts: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) diff --git a/docs/core_architecture.md b/docs/core_architecture.md new file mode 100644 index 0000000..8efe51e --- /dev/null +++ b/docs/core_architecture.md @@ -0,0 +1,309 @@ +# Core Architecture + +## Overview + +This document explains the architectural decisions, patterns, and system design of our core package. It provides insights into how the framework components interact and how to extend the system. + +> **Related Documentation** +> - See [Core Package Specification](core_package_specification.md) for implementation details +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Container Package Specification](container_package_specification.md) for dependency injection +> - See [Events Package Specification](events_package_specification.md) for event system + +## Architectural Patterns + +### 1. Service Container Architecture + +The framework is built around a central service container that manages dependencies and provides inversion of control: + +``` +┌─────────────────────────────────────────┐ +│ Application │ +│ │ +│ ┌─────────────────┐ ┌──────────────┐ │ +│ │Service Container│ │Event Dispatch│ │ +│ └─────────────────┘ └──────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌──────────────┐ │ +│ │Service Providers│ │ Pipeline │ │ +│ └─────────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────┘ +``` + +Key aspects: +- Central service container manages all dependencies +- Service providers bootstrap framework services +- Event system enables loose coupling +- Pipeline pattern for request/response handling + +### 2. Request Lifecycle + +The request flows through several layers: + +``` +┌──────────┐ ┌────────────┐ ┌─────────────┐ +│ Server │ -> │HTTP Kernel │ -> │ Pipeline │ +└──────────┘ └────────────┘ └─────────────┘ + | +┌──────────┐ ┌────────────┐ ┌─────▼─────┐ +│ Response │ <- │ Controller │ <- │ Router │ +└──────────┘ └────────────┘ └───────────┘ +``` + +Stages: +1. Server receives HTTP request +2. HTTP Kernel applies global middleware +3. Pipeline processes middleware stack +4. Router matches route +5. Controller handles request +6. Response flows back through layers + +### 3. Service Provider Pattern + +Service providers bootstrap framework components: + +``` +┌─────────────────┐ +│ Application │ +└───────┬─────────┘ + | +┌───────▼─────────┐ +│Register Providers│ +└───────┬─────────┘ + | +┌───────▼─────────┐ +│ Boot Providers │ +└───────┬─────────┘ + | +┌───────▼─────────┐ +│ Ready to Handle │ +└─────────────────┘ +``` + +Process: +1. Register core providers +2. Register package providers +3. Register application providers +4. Boot all providers +5. Application ready + +### 4. Event-Driven Architecture + +Events enable loose coupling between components: + +``` +┌────────────┐ ┌─────────────┐ ┌──────────┐ +│ Dispatcher │ -> │ Events │ -> │Listeners │ +└────────────┘ └─────────────┘ └──────────┘ + | +┌────────────┐ ┌─────────────┐ ┌──────────┐ +│ Queued │ <- │ Handler │ <- │ Process │ +└────────────┘ └─────────────┘ └──────────┘ +``` + +Features: +- Event dispatching +- Synchronous/async listeners +- Event queueing +- Event subscribers +- Event broadcasting + +## Extension Points + +### 1. Service Providers + +Create custom service providers to: +- Register services +- Bootstrap components +- Configure framework +- Add middleware +- Register routes + +```dart +class CustomServiceProvider extends ServiceProvider { + @override + void register() { + // Register services + } + + @override + void boot() { + // Bootstrap components + } +} +``` + +### 2. Middleware + +Add middleware to: +- Process requests +- Modify responses +- Handle authentication +- Rate limiting +- Custom processing + +```dart +class CustomMiddleware implements Middleware { + Future handle(Request request, Next next) async { + // Process request + var response = await next(request); + // Modify response + return response; + } +} +``` + +### 3. Event Listeners + +Create event listeners to: +- React to system events +- Handle async tasks +- Integrate external systems +- Add logging/monitoring +- Custom processing + +```dart +class CustomListener { + void handle(CustomEvent event) { + // Handle event + } +} +``` + +### 4. Console Commands + +Add console commands to: +- Run maintenance tasks +- Process queues +- Generate files +- Custom CLI tools +- System management + +```dart +class CustomCommand extends Command { + String get name => 'custom:command'; + + Future handle() async { + // Command logic + } +} +``` + +## Package Integration + +### 1. Core Package Dependencies + +``` +┌─────────────┐ +│ Core │ +└─────┬───────┘ + | +┌─────▼───────┐ ┌────────────┐ +│ Container │ --> │ Events │ +└─────────────┘ └────────────┘ + | +┌─────▼───────┐ ┌────────────┐ +│ Pipeline │ --> │ Route │ +└─────────────┘ └────────────┘ +``` + +### 2. Optional Package Integration + +``` +┌─────────────┐ +│ Core │ +└─────┬───────┘ + | +┌─────▼───────┐ ┌────────────┐ +│ Queue │ --> │ Bus │ +└─────────────┘ └────────────┘ + | +┌─────▼───────┐ ┌────────────┐ +│ Cache │ --> │ Mail │ +└─────────────┘ └────────────┘ +``` + +## Performance Considerations + +### 1. Service Container +- Optimize bindings +- Use singletons where appropriate +- Lazy load services +- Cache resolved instances + +### 2. Request Handling +- Efficient middleware pipeline +- Route caching +- Response caching +- Resource pooling + +### 3. Event System +- Async event processing +- Event batching +- Queue throttling +- Listener optimization + +### 4. Memory Management +- Clean up resources +- Limit instance caching +- Monitor memory usage +- Handle memory pressure + +## Security Considerations + +### 1. Request Validation +- Input sanitization +- CSRF protection +- XSS prevention +- SQL injection prevention + +### 2. Authentication +- Secure session handling +- Token management +- Password hashing +- Rate limiting + +### 3. Authorization +- Role-based access +- Permission checking +- Policy enforcement +- Resource protection + +### 4. Data Protection +- Encryption at rest +- Secure communication +- Data sanitization +- Audit logging + +## Development Guidelines + +### 1. Core Development +- Follow framework patterns +- Maintain backward compatibility +- Document changes +- Write tests +- Consider performance + +### 2. Package Development +- Use service providers +- Integrate with events +- Follow naming conventions +- Add package tests +- Document features + +### 3. Application Development +- Use dependency injection +- Handle events properly +- Follow middleware patterns +- Write clean code +- Test thoroughly + +## Next Steps + +1. Review architecture with team +2. Document design decisions +3. Create development guides +4. Set up monitoring +5. Plan optimizations +6. Schedule security review diff --git a/docs/core_package_specification.md b/docs/core_package_specification.md new file mode 100644 index 0000000..9ebbdd0 --- /dev/null +++ b/docs/core_package_specification.md @@ -0,0 +1,556 @@ +# Core Package Specification + +## Overview + +The Core package provides the foundation and entry point for our framework. It manages the application lifecycle, bootstraps services, handles HTTP requests, and coordinates all other framework packages. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Container Package Specification](container_package_specification.md) for dependency injection +> - See [Events Package Specification](events_package_specification.md) for application events + +## Core Features + +### 1. Application + +```dart +/// Core application class +class Application { + /// Container instance + final Container _container; + + /// Service providers + final List _providers = []; + + /// Booted flag + bool _booted = false; + + /// Environment + late final String environment; + + /// Base path + late final String basePath; + + Application(this._container) { + _container.instance(this); + _registerBaseBindings(); + _registerCoreProviders(); + } + + /// Registers base bindings + void _registerBaseBindings() { + _container.instance(_container); + _container.instance('base_path', basePath); + _container.instance('env', environment); + } + + /// Registers core providers + void _registerCoreProviders() { + register(EventServiceProvider()); + register(LogServiceProvider()); + register(RoutingServiceProvider()); + register(ConfigServiceProvider()); + } + + /// Registers a service provider + void register(ServiceProvider provider) { + provider.app = this; + provider.register(); + _providers.add(provider); + + if (_booted) { + _bootProvider(provider); + } + } + + /// Boots the application + Future boot() async { + if (_booted) return; + + for (var provider in _providers) { + await _bootProvider(provider); + } + + _booted = true; + } + + /// Boots a provider + Future _bootProvider(ServiceProvider provider) async { + await provider.callBootingCallbacks(); + await provider.boot(); + await provider.callBootedCallbacks(); + } + + /// Handles HTTP request + Future handle(Request request) async { + try { + return await _pipeline.handle(request); + } catch (e) { + return _handleError(e, request); + } + } + + /// Gets container instance + Container get container => _container; + + /// Makes instance from container + T make([dynamic parameters]) { + return _container.make(parameters); + } + + /// Gets environment + bool environment(String env) { + return this.environment == env; + } + + /// Determines if application is in production + bool get isProduction => environment == 'production'; + + /// Determines if application is in development + bool get isDevelopment => environment == 'development'; + + /// Determines if application is in testing + bool get isTesting => environment == 'testing'; + + /// Gets base path + String path([String? path]) { + return [basePath, path].where((p) => p != null).join('/'); + } +} +``` + +### 2. Service Providers + +```dart +/// Base service provider +abstract class ServiceProvider { + /// Application instance + late Application app; + + /// Container instance + Container get container => app.container; + + /// Booting callbacks + final List _bootingCallbacks = []; + + /// Booted callbacks + final List _bootedCallbacks = []; + + /// Registers services + void register(); + + /// Boots services + Future boot() async {} + + /// Registers booting callback + void booting(Function callback) { + _bootingCallbacks.add(callback); + } + + /// Registers booted callback + void booted(Function callback) { + _bootedCallbacks.add(callback); + } + + /// Calls booting callbacks + Future callBootingCallbacks() async { + for (var callback in _bootingCallbacks) { + await callback(app); + } + } + + /// Calls booted callbacks + Future callBootedCallbacks() async { + for (var callback in _bootedCallbacks) { + await callback(app); + } + } +} + +/// Event service provider +class EventServiceProvider extends ServiceProvider { + @override + void register() { + container.singleton((c) => + EventDispatcher(c) + ); + } +} + +/// Routing service provider +class RoutingServiceProvider extends ServiceProvider { + @override + void register() { + container.singleton((c) => + Router(c) + ); + } + + @override + Future boot() async { + var router = container.make(); + await loadRoutes(router); + } +} +``` + +### 3. HTTP Kernel + +```dart +/// HTTP kernel +class HttpKernel { + /// Application instance + final Application _app; + + /// Global middleware + final List middleware = [ + CheckForMaintenanceMode::class, + ValidatePostSize::class, + TrimStrings::class, + ConvertEmptyStringsToNull::class + ]; + + /// Route middleware groups + final Map> middlewareGroups = { + 'web': [ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class + ], + + 'api': [ + 'throttle:60,1', + SubstituteBindings::class + ] + }; + + /// Route middleware aliases + final Map routeMiddleware = { + 'auth': Authenticate::class, + 'auth.basic': AuthenticateWithBasicAuth::class, + 'bindings': SubstituteBindings::class, + 'cache.headers': SetCacheHeaders::class, + 'can': Authorize::class, + 'guest': RedirectIfAuthenticated::class, + 'signed': ValidateSignature::class, + 'throttle': ThrottleRequests::class, + 'verified': EnsureEmailIsVerified::class, + }; + + HttpKernel(this._app); + + /// Handles HTTP request + Future handle(Request request) async { + try { + request = await _handleGlobalMiddleware(request); + return await _app.handle(request); + } catch (e) { + return _handleError(e, request); + } + } + + /// Handles global middleware + Future _handleGlobalMiddleware(Request request) async { + var pipeline = _app.make(); + + return await pipeline + .send(request) + .through(middleware) + .then((request) => request); + } + + /// Handles error + Response _handleError(Object error, Request request) { + var handler = _app.make(); + return handler.render(error, request); + } +} +``` + +### 4. Console Kernel + +```dart +/// Console kernel +class ConsoleKernel { + /// Application instance + final Application _app; + + /// Console commands + final List commands = [ + // Framework Commands + KeyGenerateCommand::class, + ConfigCacheCommand::class, + ConfigClearCommand::class, + RouteListCommand::class, + RouteCacheCommand::class, + RouteClearCommand::class, + + // App Commands + SendEmailsCommand::class, + PruneOldRecordsCommand::class + ]; + + /// Command schedules + final Map schedules = { + 'emails:send': '0 * * * *', + 'records:prune': '0 0 * * *' + }; + + ConsoleKernel(this._app); + + /// Handles console command + Future handle(List args) async { + try { + var status = await _runCommand(args); + return status ?? 0; + } catch (e) { + _handleError(e); + return 1; + } + } + + /// Runs console command + Future _runCommand(List args) async { + var command = _resolveCommand(args); + if (command == null) return null; + + return await command.run(args); + } + + /// Resolves command from arguments + Command? _resolveCommand(List args) { + if (args.isEmpty) return null; + + var name = args.first; + var command = commands.firstWhere( + (c) => c.name == name, + orElse: () => null + ); + + if (command == null) return null; + return _app.make(command); + } + + /// Handles error + void _handleError(Object error) { + stderr.writeln(error); + } +} +``` + +### 5. Exception Handler + +```dart +/// Exception handler +class ExceptionHandler { + /// Application instance + final Application _app; + + /// Exception renderers + final Map _renderers = { + ValidationException: _renderValidationException, + AuthenticationException: _renderAuthenticationException, + AuthorizationException: _renderAuthorizationException, + NotFoundException: _renderNotFoundException, + HttpException: _renderHttpException + }; + + ExceptionHandler(this._app); + + /// Renders exception to response + Response render(Object error, Request request) { + var renderer = _renderers[error.runtimeType]; + if (renderer != null) { + return renderer(error, request); + } + + return _renderGenericException(error, request); + } + + /// Renders validation exception + Response _renderValidationException( + ValidationException e, + Request request + ) { + if (request.wantsJson) { + return Response.json({ + 'message': 'The given data was invalid.', + 'errors': e.errors + }, 422); + } + + return Response.redirect() + .back() + .withErrors(e.errors) + .withInput(request.all()); + } + + /// Renders generic exception + Response _renderGenericException(Object e, Request request) { + if (_app.isProduction) { + return Response('Server Error', 500); + } + + return Response(e.toString(), 500); + } +} +``` + +## Integration Examples + +### 1. Application Bootstrap +```dart +void main() async { + var container = Container(); + var app = Application(container) + ..environment = 'production' + ..basePath = Directory.current.path; + + await app.boot(); + + var server = HttpServer(app); + await server.start(); +} +``` + +### 2. Service Provider +```dart +class AppServiceProvider extends ServiceProvider { + @override + void register() { + container.singleton((c) => + DatabaseUserRepository(c.make()) + ); + } + + @override + Future boot() async { + var config = container.make(); + TimeZone.setDefault(config.get('app.timezone')); + } +} +``` + +### 3. HTTP Request Handling +```dart +class Server { + final HttpKernel kernel; + + Future handle(HttpRequest request) async { + var protevusRequest = await Request.fromHttpRequest(request); + var response = await kernel.handle(protevusRequest); + await response.send(request.response); + } +} +``` + +## Testing + +```dart +void main() { + group('Application', () { + test('boots providers', () async { + var app = Application(Container()); + var provider = TestProvider(); + + app.register(provider); + await app.boot(); + + expect(provider.booted, isTrue); + }); + + test('handles requests', () async { + var app = Application(Container()); + await app.boot(); + + var request = Request('GET', '/'); + var response = await app.handle(request); + + expect(response.statusCode, equals(200)); + }); + }); + + group('Service Provider', () { + test('registers services', () async { + var app = Application(Container()); + var provider = TestProvider(); + + app.register(provider); + + expect(app.make(), isNotNull); + }); + }); +} +``` + +## Next Steps + +1. Implement core application +2. Add service providers +3. Add HTTP kernel +4. Add console kernel +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing core features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Container Package Specification](container_package_specification.md) +6. Review [Events Package Specification](events_package_specification.md) + +### 2. Implementation Process +For each core feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following framework patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Follow framework patterns +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support dependency injection (see [Container Package Specification](container_package_specification.md)) +5. Support event system (see [Events Package Specification](events_package_specification.md)) + +### 4. Integration Considerations +When implementing core features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Use framework patterns consistently +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Core system must: +1. Boot efficiently +2. Handle requests quickly +3. Manage memory usage +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Core tests must: +1. Cover all core features +2. Test application lifecycle +3. Verify service providers +4. Check error handling +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Core documentation must: +1. Explain framework patterns +2. Show lifecycle examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/events_gap_analysis.md b/docs/events_gap_analysis.md new file mode 100644 index 0000000..7047c17 --- /dev/null +++ b/docs/events_gap_analysis.md @@ -0,0 +1,295 @@ +# Events Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Events package's actual implementation and our documentation, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Events Package Specification](events_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Event Discovery +class EventDiscovery { + // Need to implement: + Map discoverHandlers(Type type); + void discoverEvents(String path); +} + +// 2. After Commit Handling +class DatabaseEventDispatcher { + // Need to implement: + Future dispatchAfterCommit(T event); + void afterCommit(Function callback); +} + +// 3. Better Broadcasting +class BroadcastManager { + // Need to implement: + Channel privateChannel(String name); + PresenceChannel presenceChannel(String name); + Future broadcast(List channels, String event, dynamic data); +} +``` + +### 2. Existing Features Not Documented + +```dart +// Implemented but not documented: + +// 1. Wildcard Event Listeners +class Dispatcher { + /// Adds wildcard event listener + void _setupWildcardListen(String event, Function listener) { + _wildcards.putIfAbsent(event, () => []).add(listener); + _wildcardsCache.clear(); + } + + /// Gets wildcard listeners + List _getWildcardListeners(String eventName); +} + +// 2. Event Bus Integration +class Dispatcher { + /// EventBus integration + final EventBus _eventBus; + final Map _eventBusSubscriptions = {}; + + /// Subscribes to EventBus + void subscribe(EventBusSubscriber subscriber); +} + +// 3. Message Queue Integration +class Dispatcher { + /// MQ integration + late final MQClient? _mqClient; + + /// Queue setup + void _setupQueuesAndExchanges(); + void _startProcessingQueuedEvents(); +} +``` + +### 3. Integration Points Not Documented + +```dart +// 1. Container Integration +class Dispatcher { + /// Container reference + final Container container; + + /// Queue resolver + late final Function _queueResolver; + + /// Transaction manager resolver + late final Function _transactionManagerResolver; +} + +// 2. ReactiveX Integration +class Dispatcher { + /// Subject management + final Map> _subjects = {}; + + /// Stream access + Stream on(String event); +} + +// 3. Resource Management +class Dispatcher { + /// Cleanup + Future close(); + void dispose(); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation + +```dart +// Need to document: + +/// Listens for events using wildcard patterns. +/// +/// Example: +/// ```dart +/// dispatcher.listen('user.*', (event, data) { +/// // Handles all user events +/// }); +/// ``` +void listen(String pattern, Function listener); + +/// Subscribes to event streams using ReactiveX. +/// +/// Example: +/// ```dart +/// dispatcher.on('user.created') +/// .listen((event) { +/// // Handle user created event +/// }); +/// ``` +Stream on(String event); +``` + +### 2. Missing Integration Examples + +```dart +// Need examples for: + +// 1. EventBus Integration +var subscriber = MyEventSubscriber(); +dispatcher.subscribe(subscriber); + +// 2. Message Queue Integration +dispatcher.setMQClient(mqClient); +await dispatcher.push('user.created', userData); + +// 3. ReactiveX Integration +dispatcher.on('user.*') + .where((e) => e.type == 'premium') + .listen((e) => handlePremiumUser(e)); +``` + +### 3. Missing Test Coverage + +```dart +// Need tests for: + +void main() { + group('Wildcard Events', () { + test('matches wildcard patterns', () { + var dispatcher = Dispatcher(container); + var received = []; + + dispatcher.listen('user.*', (event, _) { + received.add(event); + }); + + await dispatcher.dispatch('user.created'); + await dispatcher.dispatch('user.updated'); + + expect(received, ['user.created', 'user.updated']); + }); + }); + + group('Queue Integration', () { + test('queues events properly', () async { + var dispatcher = Dispatcher(container); + dispatcher.setMQClient(mockClient); + + await dispatcher.push('delayed.event', data); + + verify(() => mockClient.sendMessage( + exchangeName: any, + routingKey: any, + message: any + )).called(1); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Event discovery (Laravel compatibility) + - After commit handling (Laravel compatibility) + - Better broadcasting support + +2. **Medium Priority** + - Better queue integration + - Enhanced wildcard support + - Performance optimizations + +3. **Low Priority** + - Additional helper methods + - Extended testing utilities + - Debug/profiling tools + +## Next Steps + +1. **Implementation Tasks** + - Add event discovery + - Add after commit handling + - Enhance broadcasting + - Improve queue integration + +2. **Documentation Tasks** + - Document wildcard events + - Document EventBus integration + - Document MQ integration + - Add integration examples + +3. **Testing Tasks** + - Add wildcard event tests + - Add queue integration tests + - Add ReactiveX integration tests + - Add resource cleanup tests + +Would you like me to: +1. Start implementing missing features? +2. Update documentation for existing features? +3. Create test cases for missing coverage? + +## Development Guidelines + +### 1. Getting Started +Before implementing event features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Events Package Specification](events_package_specification.md) + +### 2. Implementation Process +For each event feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Events Package Specification](events_package_specification.md) + +### 4. Integration Considerations +When implementing event features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Events system must: +1. Handle high event throughput +2. Minimize memory usage +3. Support async operations +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Event tests must: +1. Cover all event scenarios +2. Test async behavior +3. Verify queue integration +4. Check broadcasting +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Event documentation must: +1. Explain event patterns +2. Show integration examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/events_package_specification.md b/docs/events_package_specification.md new file mode 100644 index 0000000..28e82ae --- /dev/null +++ b/docs/events_package_specification.md @@ -0,0 +1,453 @@ +# Events Package Specification + +## Overview + +The Events package provides a robust event system that matches Laravel's event functionality while leveraging Dart's async capabilities. It integrates with our Queue, Bus, and Database packages to provide a complete event handling solution. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Contracts Package Specification](contracts_package_specification.md) for event contracts + +## Core Features + +### 1. Event Dispatcher + +```dart +/// Core event dispatcher implementation +class EventDispatcher implements EventDispatcherContract { + final Container _container; + final Map> _listeners = {}; + final List _subscribers = {}; + final QueueContract? _queue; + final BroadcasterContract? _broadcaster; + final List _afterCommitEvents = []; + + EventDispatcher( + this._container, { + QueueContract? queue, + BroadcasterContract? broadcaster + }) : _queue = queue, + _broadcaster = broadcaster; + + @override + void listen(void Function(T event) listener) { + _listeners.putIfAbsent(T, () => []).add( + EventListener(listener) + ); + } + + @override + Future dispatch(T event) async { + var listeners = _listeners[T] ?? []; + + // Handle after commit events + if (event is ShouldDispatchAfterCommit && _isWithinTransaction()) { + _afterCommitEvents.add(event); + return; + } + + // Handle queued events + if (event is ShouldQueue && _queue != null) { + await _queueEvent(event, listeners); + return; + } + + // Handle broadcasting + if (event is ShouldBroadcast && _broadcaster != null) { + await _broadcastEvent(event); + } + + // Notify listeners + await _notifyListeners(event, listeners); + } + + @override + Future dispatchAfterCommit(T event) async { + if (_isWithinTransaction()) { + _afterCommitEvents.add(event); + } else { + await dispatch(event); + } + } + + bool _isWithinTransaction() { + if (_container.has()) { + var db = _container.make(); + return db.transactionLevel > 0; + } + return false; + } + + Future _dispatchAfterCommitEvents() async { + var events = List.from(_afterCommitEvents); + _afterCommitEvents.clear(); + + for (var event in events) { + await dispatch(event); + } + } +} +``` + +### 2. Event Discovery + +```dart +/// Discovers event handlers through reflection and attributes +class EventDiscovery { + final Container _container; + final Reflector _reflector; + + EventDiscovery(this._container, this._reflector); + + /// Discovers event handlers in a directory + Future discoverEvents(String path) async { + var files = Directory(path).listSync(recursive: true); + + for (var file in files) { + if (file.path.endsWith('.dart')) { + await _processFile(file.path); + } + } + } + + Future _processFile(String path) async { + var library = await _reflector.loadLibrary(path); + + for (var type in library.declarations.values) { + if (type is ClassMirror) { + _processClass(type); + } + } + } + + void _processClass(ClassMirror classMirror) { + // Find @Handles annotations + for (var method in classMirror.declarations.values) { + if (method is MethodMirror) { + var handles = method.metadata + .firstWhere((m) => m.type == Handles, + orElse: () => null); + + if (handles != null) { + var eventType = handles.getField('event').reflectee; + _registerHandler(classMirror.reflectedType, method.simpleName, eventType); + } + } + } + } + + void _registerHandler(Type classType, Symbol methodName, Type eventType) { + var instance = _container.make(classType); + var dispatcher = _container.make(); + + dispatcher.listen(eventType, (event) { + var mirror = reflect(instance); + mirror.invoke(methodName, [event]); + }); + } +} +``` + +### 3. Event Broadcasting + +```dart +/// Contract for event broadcasters +abstract class BroadcasterContract { + /// Broadcasts an event + Future broadcast( + List channels, + String eventName, + dynamic data + ); + + /// Creates a private channel + Channel privateChannel(String name); + + /// Creates a presence channel + PresenceChannel presenceChannel(String name); +} + +/// Pusher event broadcaster +class PusherBroadcaster implements BroadcasterContract { + final PusherClient _client; + final AuthManager _auth; + + PusherBroadcaster(this._client, this._auth); + + @override + Future broadcast( + List channels, + String eventName, + dynamic data + ) async { + for (var channel in channels) { + await _client.trigger(channel, eventName, data); + } + } + + @override + Channel privateChannel(String name) { + return PrivateChannel(_client, _auth, name); + } + + @override + PresenceChannel presenceChannel(String name) { + return PresenceChannel(_client, _auth, name); + } +} +``` + +### 4. Integration with Queue + +```dart +/// Job for processing queued events +class QueuedEventJob implements Job { + final dynamic event; + final List listeners; + final Map data; + + QueuedEventJob({ + required this.event, + required this.listeners, + this.data = const {} + }); + + @override + Future handle() async { + for (var listener in listeners) { + try { + await listener.handle(event); + } catch (e) { + await _handleFailure(e); + } + } + } + + @override + Future failed([Exception? e]) async { + if (event is FailedEventHandler) { + await (event as FailedEventHandler).failed(e); + } + } + + @override + int get tries => event is HasTries ? (event as HasTries).tries : 1; + + @override + Duration? get timeout => + event is HasTimeout ? (event as HasTimeout).timeout : null; +} +``` + +### 5. Integration with Bus + +```dart +/// Event command for command bus integration +class EventCommand implements Command { + final dynamic event; + final List listeners; + + EventCommand(this.event, this.listeners); + + @override + Type get handler => EventCommandHandler; +} + +/// Handler for event commands +class EventCommandHandler implements Handler { + final EventDispatcher _events; + + EventCommandHandler(this._events); + + @override + Future handle(EventCommand command) async { + await _events._notifyListeners( + command.event, + command.listeners + ); + } +} +``` + +## Usage Examples + +### Basic Event Handling +```dart +// Define event +class OrderShipped { + final Order order; + OrderShipped(this.order); +} + +// Create listener +@Handles(OrderShipped) +class OrderShippedListener { + void handle(OrderShipped event) { + // Handle event + } +} + +// Register and dispatch +dispatcher.listen((event) { + // Handle event +}); + +await dispatcher.dispatch(OrderShipped(order)); +``` + +### After Commit Events +```dart +class OrderCreated implements ShouldDispatchAfterCommit { + final Order order; + OrderCreated(this.order); +} + +// In transaction +await db.transaction((tx) async { + var order = await tx.create(orderData); + await dispatcher.dispatchAfterCommit(OrderCreated(order)); +}); +``` + +### Broadcasting +```dart +class MessageSent implements ShouldBroadcast { + final Message message; + + @override + List broadcastOn() => [ + 'private-chat.${message.roomId}' + ]; + + @override + Map get broadcastWith => { + 'id': message.id, + 'content': message.content, + 'user': message.user.toJson() + }; +} + +// Create private channel +var channel = broadcaster.privateChannel('chat.123'); +await channel.whisper('typing', {'user': 'john'}); +``` + +### Queue Integration +```dart +class ProcessOrder implements ShouldQueue { + final Order order; + + @override + String get queue => 'orders'; + + @override + int get tries => 3; + + @override + Duration get timeout => Duration(minutes: 5); +} + +// Dispatch queued event +await dispatcher.dispatch(ProcessOrder(order)); +``` + +## Testing + +```dart +void main() { + group('Event Dispatcher', () { + test('dispatches after commit', () async { + var dispatcher = MockEventDispatcher(); + var db = MockDatabase(); + + await db.transaction((tx) async { + await dispatcher.dispatchAfterCommit(OrderShipped(order)); + expect(dispatcher.hasAfterCommitEvents, isTrue); + }); + + expect(dispatcher.hasAfterCommitEvents, isFalse); + verify(() => dispatcher.dispatch(any())).called(1); + }); + + test('discovers event handlers', () async { + var discovery = EventDiscovery(container, reflector); + await discovery.discoverEvents('lib/events'); + + var dispatcher = container.make(); + await dispatcher.dispatch(OrderShipped(order)); + + verify(() => orderListener.handle(any())).called(1); + }); + }); +} +``` + +## Next Steps + +1. Complete after commit handling +2. Enhance event discovery +3. Add more broadcast drivers +4. Improve queue integration +5. Add performance optimizations +6. Write more tests + +Would you like me to enhance any other package specifications? + +## Development Guidelines + +### 1. Getting Started +Before implementing event features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Understand [Contracts Package Specification](contracts_package_specification.md) + +### 2. Implementation Process +For each event feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Implement required contracts (see [Contracts Package Specification](contracts_package_specification.md)) + +### 4. Integration Considerations +When implementing events: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) +5. Implement all contracts from [Contracts Package Specification](contracts_package_specification.md) + +### 5. Performance Guidelines +Event system must: +1. Handle high throughput efficiently +2. Minimize memory usage +3. Support async operations +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Event tests must: +1. Cover all event scenarios +2. Test async behavior +3. Verify queue integration +4. Check broadcasting +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Event documentation must: +1. Explain event patterns +2. Show integration examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/filesystem_gap_analysis.md b/docs/filesystem_gap_analysis.md new file mode 100644 index 0000000..f671a47 --- /dev/null +++ b/docs/filesystem_gap_analysis.md @@ -0,0 +1,349 @@ +# FileSystem Package Gap Analysis + +## Overview + +This document analyzes the gaps between our current filesystem handling (in Core package) and Laravel's FileSystem package functionality, identifying what needs to be implemented as a standalone FileSystem package. + +> **Related Documentation** +> - See [FileSystem Package Specification](filesystem_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Package Structure +```dart +// Need to create dedicated FileSystem package: + +packages/filesystem/ +├── lib/ +│ ├── src/ +│ │ ├── filesystem.dart +│ │ ├── filesystem_manager.dart +│ │ ├── drivers/ +│ │ │ ├── local_driver.dart +│ │ │ ├── s3_driver.dart +│ │ │ └── gcs_driver.dart +│ │ └── contracts/ +│ │ ├── filesystem.dart +│ │ └── driver.dart +│ └── filesystem.dart +├── test/ +└── example/ +``` + +### 2. Missing Core Features +```dart +// 1. Filesystem Manager +class FilesystemManager { + // Need to implement: + Filesystem disk([String? name]); + void extend(String driver, FilesystemDriver Function() callback); + FilesystemDriver createDriver(Map config); +} + +// 2. Filesystem Implementation +class Filesystem { + // Need to implement: + Future exists(String path); + Future get(String path); + Future put(String path, dynamic contents, [Map? options]); + Future delete(String path); + Future copy(String from, String to); + Future move(String from, String to); + Future url(String path); + Future>> readStream(String path); + Future writeStream(String path, Stream> contents); +} + +// 3. Driver Implementations +class LocalDriver { + // Need to implement: + Future ensureDirectory(String path); + Future setVisibility(String path, String visibility); + Future> getMetadata(String path); +} +``` + +### 3. Missing Laravel Features +```dart +// 1. Cloud Storage +class S3Driver { + // Need to implement: + Future upload(String path, dynamic contents, String visibility); + Future temporaryUrl(String path, Duration expiration); + Future setVisibility(String path, String visibility); +} + +// 2. Directory Operations +class DirectoryOperations { + // Need to implement: + Future> files(String directory); + Future> allFiles(String directory); + Future> directories(String directory); + Future> allDirectories(String directory); + Future makeDirectory(String path); + Future deleteDirectory(String directory); +} + +// 3. File Visibility +class VisibilityConverter { + // Need to implement: + String toOctal(String visibility); + String fromOctal(String permissions); + bool isPublic(String path); + bool isPrivate(String path); +} +``` + +## Integration Gaps + +### 1. Container Integration +```dart +// Need to implement: + +class FilesystemServiceProvider { + void register() { + // Register filesystem manager + container.singleton((c) => + FilesystemManager( + config: c.make() + ) + ); + + // Register default filesystem + container.singleton((c) => + c.make().disk() + ); + } +} +``` + +### 2. Config Integration +```dart +// Need to implement: + +// config/filesystems.dart +class FilesystemsConfig { + static Map get config => { + 'default': 'local', + 'disks': { + 'local': { + 'driver': 'local', + 'root': 'storage/app' + }, + 's3': { + 'driver': 's3', + 'key': env('AWS_ACCESS_KEY_ID'), + 'secret': env('AWS_SECRET_ACCESS_KEY'), + 'region': env('AWS_DEFAULT_REGION'), + 'bucket': env('AWS_BUCKET') + } + } + }; +} +``` + +### 3. Event Integration +```dart +// Need to implement: + +class FilesystemEvents { + // File events + static const String writing = 'filesystem.writing'; + static const String written = 'filesystem.written'; + static const String deleting = 'filesystem.deleting'; + static const String deleted = 'filesystem.deleted'; + + // Directory events + static const String makingDirectory = 'filesystem.making_directory'; + static const String madeDirectory = 'filesystem.made_directory'; + static const String deletingDirectory = 'filesystem.deleting_directory'; + static const String deletedDirectory = 'filesystem.deleted_directory'; +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Manages filesystem operations across multiple storage drivers. +/// +/// Provides a unified API for working with files across different storage systems: +/// ```dart +/// // Store a file +/// await storage.put('avatars/user1.jpg', fileContents); +/// +/// // Get a file +/// var contents = await storage.get('avatars/user1.jpg'); +/// ``` +class Filesystem { + /// Stores a file at the specified path. + /// + /// Options can include: + /// - visibility: 'public' or 'private' + /// - mime: MIME type of the file + Future put(String path, dynamic contents, [Map? options]); +} +``` + +### 2. Missing Usage Examples +```dart +// Need examples for: + +// 1. Basic File Operations +var storage = Storage.disk(); +await storage.put('file.txt', 'Hello World'); +var contents = await storage.get('file.txt'); +await storage.delete('file.txt'); + +// 2. Stream Operations +var fileStream = File('large.zip').openRead(); +await storage.writeStream('uploads/large.zip', fileStream); +var downloadStream = await storage.readStream('uploads/large.zip'); + +// 3. Cloud Storage +var s3 = Storage.disk('s3'); +await s3.put( + 'images/photo.jpg', + photoBytes, + {'visibility': 'public'} +); +var url = await s3.url('images/photo.jpg'); +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Local Driver', () { + test('handles file operations', () async { + var storage = Filesystem(LocalDriver(root: 'storage')); + + await storage.put('test.txt', 'contents'); + expect(await storage.exists('test.txt'), isTrue); + expect(await storage.get('test.txt'), equals('contents')); + + await storage.delete('test.txt'); + expect(await storage.exists('test.txt'), isFalse); + }); + }); + + group('S3 Driver', () { + test('handles cloud operations', () async { + var storage = Filesystem(S3Driver(config)); + + await storage.put('test.txt', 'contents', { + 'visibility': 'public' + }); + + var url = await storage.url('test.txt'); + expect(url, startsWith('https://')); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Create FileSystem package structure + - Implement core filesystem + - Add local driver + - Add basic operations + +2. **Medium Priority** + - Add cloud drivers + - Add streaming support + - Add directory operations + - Add container integration + +3. **Low Priority** + - Add helper functions + - Add testing utilities + - Add debugging tools + +## Next Steps + +1. **Package Creation** + - Create package structure + - Move filesystem code from Core + - Add package dependencies + - Setup testing + +2. **Core Implementation** + - Implement FilesystemManager + - Implement Filesystem + - Implement LocalDriver + - Add cloud drivers + +3. **Integration Implementation** + - Add container integration + - Add config support + - Add event support + - Add service providers + +Would you like me to: +1. Create the FileSystem package structure? +2. Start implementing core features? +3. Create detailed implementation plans? + +## Development Guidelines + +### 1. Getting Started +Before implementing filesystem features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [FileSystem Package Specification](filesystem_package_specification.md) + +### 2. Implementation Process +For each filesystem feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [FileSystem Package Specification](filesystem_package_specification.md) + +### 4. Integration Considerations +When implementing filesystem features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Filesystem system must: +1. Handle large files efficiently +2. Use streaming where appropriate +3. Minimize memory usage +4. Support concurrent operations +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Filesystem tests must: +1. Cover all file operations +2. Test streaming behavior +3. Verify cloud storage +4. Check metadata handling +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Filesystem documentation must: +1. Explain filesystem patterns +2. Show driver examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/filesystem_package_specification.md b/docs/filesystem_package_specification.md new file mode 100644 index 0000000..6e0f171 --- /dev/null +++ b/docs/filesystem_package_specification.md @@ -0,0 +1,554 @@ +# FileSystem Package Specification + +## Overview + +The FileSystem package provides a robust abstraction layer for file operations, matching Laravel's filesystem functionality. It supports local and cloud storage systems through a unified API, with support for streaming, visibility control, and metadata management. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Contracts Package Specification](contracts_package_specification.md) for filesystem contracts + +## Core Features + +### 1. Filesystem Manager + +```dart +/// Manages filesystem drivers +class FilesystemManager implements FilesystemFactory { + /// Available filesystem drivers + final Map _drivers = {}; + + /// Default driver name + final String _defaultDriver; + + /// Configuration repository + final ConfigContract _config; + + FilesystemManager(this._config) + : _defaultDriver = _config.get('filesystems.default', 'local'); + + @override + Filesystem disk([String? name]) { + name ??= _defaultDriver; + + return _drivers.putIfAbsent(name, () { + var config = _getConfig(name!); + var driver = _createDriver(config); + return Filesystem(driver); + }); + } + + /// Creates a driver instance + FilesystemDriver _createDriver(Map config) { + switch (config['driver']) { + case 'local': + return LocalDriver(config); + case 's3': + return S3Driver(config); + case 'gcs': + return GoogleCloudDriver(config); + default: + throw UnsupportedError( + 'Unsupported filesystem driver: ${config['driver']}' + ); + } + } + + /// Gets configuration for driver + Map _getConfig(String name) { + var config = _config.get('filesystems.disks.$name'); + if (config == null) { + throw ArgumentError('Disk [$name] not configured.'); + } + return config; + } +} +``` + +### 2. Filesystem Implementation + +```dart +/// Core filesystem implementation +class Filesystem implements FilesystemContract { + /// The filesystem driver + final FilesystemDriver _driver; + + Filesystem(this._driver); + + @override + Future exists(String path) { + return _driver.exists(path); + } + + @override + Future get(String path) { + return _driver.get(path); + } + + @override + Stream> readStream(String path) { + return _driver.readStream(path); + } + + @override + Future put(String path, dynamic contents, [Map? options]) { + return _driver.put(path, contents, options); + } + + @override + Future putStream(String path, Stream> contents, [Map? options]) { + return _driver.putStream(path, contents, options); + } + + @override + Future delete(String path) { + return _driver.delete(path); + } + + @override + Future copy(String from, String to) { + return _driver.copy(from, to); + } + + @override + Future move(String from, String to) { + return _driver.move(from, to); + } + + @override + Future url(String path) { + return _driver.url(path); + } + + @override + Future> metadata(String path) { + return _driver.metadata(path); + } + + @override + Future size(String path) { + return _driver.size(path); + } + + @override + Future mimeType(String path) { + return _driver.mimeType(path); + } + + @override + Future lastModified(String path) { + return _driver.lastModified(path); + } +} +``` + +### 3. Local Driver + +```dart +/// Local filesystem driver +class LocalDriver implements FilesystemDriver { + /// Root path for local filesystem + final String _root; + + /// Default visibility + final String _visibility; + + LocalDriver(Map config) + : _root = config['root'], + _visibility = config['visibility'] ?? 'private'; + + @override + Future exists(String path) async { + return File(_fullPath(path)).exists(); + } + + @override + Future get(String path) async { + return File(_fullPath(path)).readAsString(); + } + + @override + Stream> readStream(String path) { + return File(_fullPath(path)).openRead(); + } + + @override + Future put(String path, dynamic contents, [Map? options]) async { + var file = File(_fullPath(path)); + await file.create(recursive: true); + + if (contents is String) { + await file.writeAsString(contents); + } else if (contents is List) { + await file.writeAsBytes(contents); + } else { + throw ArgumentError('Invalid content type'); + } + + await _setVisibility(file, options?['visibility'] ?? _visibility); + } + + @override + Future putStream(String path, Stream> contents, [Map? options]) async { + var file = File(_fullPath(path)); + await file.create(recursive: true); + + var sink = file.openWrite(); + await contents.pipe(sink); + await sink.close(); + + await _setVisibility(file, options?['visibility'] ?? _visibility); + } + + /// Gets full path for file + String _fullPath(String path) { + return p.join(_root, path); + } + + /// Sets file visibility + Future _setVisibility(File file, String visibility) async { + // Set file permissions based on visibility + if (visibility == 'public') { + await file.setPermissions( + unix: 0644, + windows: FilePermissions.readWrite + ); + } else { + await file.setPermissions( + unix: 0600, + windows: FilePermissions.readWriteExecute + ); + } + } +} +``` + +### 4. Cloud Drivers + +```dart +/// Amazon S3 driver +class S3Driver implements FilesystemDriver { + /// S3 client + final S3Client _client; + + /// Bucket name + final String _bucket; + + /// Optional path prefix + final String? _prefix; + + S3Driver(Map config) + : _client = S3Client( + region: config['region'], + credentials: AWSCredentials( + accessKey: config['key'], + secretKey: config['secret'] + ) + ), + _bucket = config['bucket'], + _prefix = config['prefix']; + + @override + Future exists(String path) async { + try { + await _client.headObject( + bucket: _bucket, + key: _prefixPath(path) + ); + return true; + } catch (e) { + return false; + } + } + + @override + Future put(String path, dynamic contents, [Map? options]) async { + await _client.putObject( + bucket: _bucket, + key: _prefixPath(path), + body: contents, + acl: options?['visibility'] == 'public' + ? 'public-read' + : 'private' + ); + } + + /// Adds prefix to path + String _prefixPath(String path) { + return _prefix != null ? '$_prefix/$path' : path; + } +} + +/// Google Cloud Storage driver +class GoogleCloudDriver implements FilesystemDriver { + /// Storage client + final Storage _storage; + + /// Bucket name + final String _bucket; + + GoogleCloudDriver(Map config) + : _storage = Storage( + projectId: config['project_id'], + credentials: config['credentials'] + ), + _bucket = config['bucket']; + + @override + Future exists(String path) async { + try { + await _storage.bucket(_bucket).file(path).exists(); + return true; + } catch (e) { + return false; + } + } + + @override + Future put(String path, dynamic contents, [Map? options]) async { + var file = _storage.bucket(_bucket).file(path); + + if (contents is String) { + await file.writeAsString(contents); + } else if (contents is List) { + await file.writeAsBytes(contents); + } else { + throw ArgumentError('Invalid content type'); + } + + if (options?['visibility'] == 'public') { + await file.makePublic(); + } + } +} +``` + +## Integration with Container + +```dart +/// Registers filesystem services +class FilesystemServiceProvider extends ServiceProvider { + @override + void register() { + // Register filesystem factory + container.singleton((c) { + return FilesystemManager(c.make()); + }); + + // Register default filesystem + container.singleton((c) { + return c.make().disk(); + }); + } +} +``` + +## Usage Examples + +### Basic File Operations +```dart +// Get default disk +var storage = Storage.disk(); + +// Check if file exists +if (await storage.exists('file.txt')) { + // Read file contents + var contents = await storage.get('file.txt'); + + // Write file contents + await storage.put('new-file.txt', contents); + + // Delete file + await storage.delete('file.txt'); +} +``` + +### Stream Operations +```dart +// Read file as stream +var stream = storage.readStream('large-file.txt'); + +// Write stream to file +await storage.putStream( + 'output.txt', + stream, + {'visibility': 'public'} +); +``` + +### Cloud Storage +```dart +// Use S3 disk +var s3 = Storage.disk('s3'); + +// Upload file +await s3.put( + 'uploads/image.jpg', + imageBytes, + {'visibility': 'public'} +); + +// Get public URL +var url = await s3.url('uploads/image.jpg'); +``` + +### File Metadata +```dart +// Get file metadata +var meta = await storage.metadata('document.pdf'); +print('Size: ${meta['size']}'); +print('Type: ${meta['mime_type']}'); +print('Modified: ${meta['last_modified']}'); +``` + +## Testing + +```dart +void main() { + group('Filesystem Tests', () { + late Filesystem storage; + + setUp(() { + storage = Filesystem(MockDriver()); + }); + + test('should check file existence', () async { + expect(await storage.exists('test.txt'), isTrue); + expect(await storage.exists('missing.txt'), isFalse); + }); + + test('should read and write files', () async { + await storage.put('test.txt', 'contents'); + var contents = await storage.get('test.txt'); + expect(contents, equals('contents')); + }); + + test('should handle streams', () async { + var input = Stream.fromIterable([ + [1, 2, 3], + [4, 5, 6] + ]); + + await storage.putStream('test.bin', input); + var output = storage.readStream('test.bin'); + + expect( + await output.toList(), + equals([[1, 2, 3], [4, 5, 6]]) + ); + }); + }); +} +``` + +## Performance Considerations + +1. **Streaming Large Files** +```dart +// Use streams for large files +class Filesystem { + Future copyLarge(String from, String to) async { + await readStream(from) + .pipe(writeStream(to)); + } +} +``` + +2. **Caching URLs** +```dart +class CachingFilesystem implements FilesystemContract { + final Cache _cache; + final Duration _ttl; + + @override + Future url(String path) async { + var key = 'file_url:$path'; + return _cache.remember(key, _ttl, () { + return _driver.url(path); + }); + } +} +``` + +3. **Batch Operations** +```dart +class Filesystem { + Future putMany(Map files) async { + await Future.wait( + files.entries.map((e) => + put(e.key, e.value) + ) + ); + } +} +``` + +## Next Steps + +1. Implement core filesystem +2. Add local driver +3. Add cloud drivers +4. Create manager +5. Write tests +6. Add benchmarks + +Would you like me to focus on implementing any specific part of these packages or continue with other documentation? + +## Development Guidelines + +### 1. Getting Started +Before implementing filesystem features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Understand [Contracts Package Specification](contracts_package_specification.md) + +### 2. Implementation Process +For each filesystem feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Implement required contracts (see [Contracts Package Specification](contracts_package_specification.md)) + +### 4. Integration Considerations +When implementing filesystem features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) +5. Implement all contracts from [Contracts Package Specification](contracts_package_specification.md) + +### 5. Performance Guidelines +Filesystem system must: +1. Handle large files efficiently +2. Use streaming where appropriate +3. Minimize memory usage +4. Support concurrent operations +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Filesystem tests must: +1. Cover all file operations +2. Test streaming behavior +3. Verify cloud storage +4. Check metadata handling +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Filesystem documentation must: +1. Explain filesystem patterns +2. Show driver examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/foundation_integration_guide.md b/docs/foundation_integration_guide.md new file mode 100644 index 0000000..07c5e67 --- /dev/null +++ b/docs/foundation_integration_guide.md @@ -0,0 +1,316 @@ +# Foundation Integration Guide + +## Overview + +This guide demonstrates how Level 0 and Level 1 packages work together to provide the foundation for the framework. It includes implementation priorities, integration patterns, and best practices. + +## Implementation Timeline + +### Phase 1: Core Foundation (Level 0) + +#### Week 1: Contracts Package +```dart +Priority: Highest +Dependencies: None +Steps: +1. Define core interfaces +2. Create base exceptions +3. Add documentation +4. Write interface tests +``` + +#### Week 2: Support Package +```dart +Priority: Highest +Dependencies: Contracts +Steps: +1. Implement collections +2. Add string helpers +3. Create service provider base +4. Add utility functions +``` + +#### Weeks 3-4: Container Package +```dart +Priority: Highest +Dependencies: Contracts, Support +Steps: +1. Implement core container +2. Add contextual binding +3. Add method injection +4. Add tagged bindings +5. Implement caching +``` + +#### Week 5: Pipeline Package +```dart +Priority: High +Dependencies: Contracts, Support, Container +Steps: +1. Implement core pipeline +2. Add pipeline hub +3. Create middleware support +4. Add async handling +``` + +### Phase 2: Infrastructure (Level 1) + +#### Weeks 6-7: Events Package +```dart +Priority: High +Dependencies: All Level 0 +Steps: +1. Implement event dispatcher +2. Add event discovery +3. Create subscriber support +4. Add queueing integration +``` + +#### Week 8: Config Package +```dart +Priority: High +Dependencies: All Level 0 +Steps: +1. Implement config repository +2. Add environment loading +3. Create config caching +4. Add array casting +``` + +#### Weeks 9-10: FileSystem Package +```dart +Priority: High +Dependencies: All Level 0 +Steps: +1. Implement filesystem manager +2. Create local driver +3. Add cloud drivers +4. Implement streaming +``` + +## Integration Examples + +### 1. Service Provider Integration +```dart +/// Example showing how packages integrate through service providers +void main() { + var container = Container(); + + // Register foundation services + container.register(SupportServiceProvider()); + container.register(PipelineServiceProvider()); + container.register(EventServiceProvider()); + container.register(ConfigServiceProvider()); + container.register(FilesystemServiceProvider()); + + // Boot application + await container.bootProviders(); +} +``` + +### 2. Event-Driven File Operations +```dart +/// Example showing Events and FileSystem integration +class FileUploadHandler { + final EventDispatcherContract _events; + final FilesystemContract _storage; + + Future handleUpload(Upload upload) async { + // Store file using FileSystem + await _storage.put( + 'uploads/${upload.filename}', + upload.contents, + {'visibility': 'public'} + ); + + // Dispatch event using Events + await _events.dispatch(FileUploaded( + filename: upload.filename, + size: upload.size, + url: await _storage.url('uploads/${upload.filename}') + )); + } +} +``` + +### 3. Configuration-Based Pipeline +```dart +/// Example showing Config and Pipeline integration +class RequestHandler { + final ConfigContract _config; + final Pipeline _pipeline; + + Future handle(Request request) async { + // Get middleware from config + var middleware = _config.get('http.middleware', []) + .map((m) => container.make(m)) + .toList(); + + // Process request through pipeline + return _pipeline + .through(middleware) + .send(request) + .then((request) => processRequest(request)); + } +} +``` + +## Common Integration Patterns + +### 1. Service Provider Pattern +```dart +abstract class ServiceProvider { + void register() { + container.singleton((c) => + ServiceImpl( + c.make(), + c.make(), + c.make() + ) + ); + } +} +``` + +### 2. Event-Driven Pattern +```dart +class EventDrivenService { + final EventDispatcherContract events; + + void initialize() { + events.listen(_handleConfigChange); + events.listen(_handleStorageEvent); + } +} +``` + +### 3. Pipeline Pattern +```dart +class ServicePipeline { + final Pipeline pipeline; + + ServicePipeline(this.pipeline) { + pipeline.through([ + ConfigMiddleware(container.make()), + EventMiddleware(container.make()), + StorageMiddleware(container.make()) + ]); + } +} +``` + +## Testing Strategy + +### 1. Unit Tests +```dart +void main() { + group('Package Tests', () { + test('core functionality', () { + // Test core features + }); + + test('integration points', () { + // Test integration with other packages + }); + }); +} +``` + +### 2. Integration Tests +```dart +void main() { + group('Integration Tests', () { + late Container container; + + setUp(() { + container = Container(); + container.register(SupportServiceProvider()); + container.register(EventServiceProvider()); + }); + + test('should handle file upload with events', () async { + var handler = container.make(); + var events = container.make(); + + var received = []; + events.listen((event) { + received.add(event); + }); + + await handler.handleUpload(testUpload); + expect(received, hasLength(1)); + }); + }); +} +``` + +## Quality Checklist + +### 1. Code Quality +- [ ] Follows style guide +- [ ] Uses static analysis +- [ ] Has documentation +- [ ] Has tests +- [ ] Handles errors + +### 2. Package Quality +- [ ] Has README +- [ ] Has examples +- [ ] Has changelog +- [ ] Has license +- [ ] Has CI/CD + +### 3. Integration Quality +- [ ] Works with container +- [ ] Supports events +- [ ] Uses configuration +- [ ] Has providers + +## Best Practices + +1. **Use Service Providers** +```dart +// Register dependencies in providers +class ServiceProvider { + void register() { + // Register all required services + } +} +``` + +2. **Event-Driven Communication** +```dart +// Use events for cross-package communication +class Service { + final EventDispatcherContract _events; + + Future doSomething() async { + await _events.dispatch(SomethingHappened()); + } +} +``` + +3. **Configuration-Based Setup** +```dart +// Use configuration for service setup +class Service { + void initialize(ConfigContract config) { + if (config.get('service.enabled')) { + // Initialize service + } + } +} +``` + +## Next Steps + +1. Follow implementation timeline +2. Review package dependencies +3. Implement integration tests +4. Document common patterns +5. Create example applications + +Would you like me to: +1. Start implementing a specific package? +2. Create detailed integration tests? +3. Build example applications? diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 0000000..cb49d72 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,142 @@ +# Getting Started Guide + +## Overview + +This guide helps developers get started with implementing and contributing to the framework's foundation packages. It provides step-by-step instructions for setting up the development environment, understanding the codebase, and making contributions. + +## Key Documentation + +Before starting, familiarize yourself with our core documentation: + +1. **Architecture & Implementation** + - [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) - Overall implementation status and plans + - [Foundation Integration Guide](foundation_integration_guide.md) - How packages work together + - [Testing Guide](testing_guide.md) - Testing approaches and standards + +2. **Package Documentation** + - [Container Package](container_package_specification.md) - Dependency injection system + - [Container Gap Analysis](container_gap_analysis.md) - Implementation status and plans + - More package docs coming soon... + +3. **Development Setup** + - [Melos Configuration](melos_config.md) - Build and development tools + +[Previous content remains the same until Project Structure section, then update with:] + +## Project Structure + +### 1. Package Organization +``` +platform/ +├── packages/ +│ ├── container/ # Dependency injection +│ │ ├── container/ # Core container +│ │ └── container_generator/ # Code generation +│ ├── core/ # Framework core +│ ├── events/ # Event system +│ ├── model/ # Model system +│ ├── pipeline/ # Pipeline pattern +│ ├── process/ # Process management +│ ├── queue/ # Queue system +│ ├── route/ # Routing system +│ ├── support/ # Utilities +│ └── testing/ # Testing utilities +├── apps/ # Example applications +├── config/ # Configuration files +├── docs/ # Documentation +├── examples/ # Usage examples +├── resources/ # Additional resources +├── scripts/ # Development scripts +├── templates/ # Project templates +└── tests/ # Integration tests +``` + +### 2. Package Structure +``` +package/ +├── lib/ +│ ├── src/ +│ │ ├── core/ # Core implementation +│ │ ├── contracts/ # Package interfaces +│ │ └── support/ # Package utilities +│ └── package.dart # Public API +├── test/ +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests +│ └── performance/ # Performance tests +├── example/ # Usage examples +└── README.md # Package documentation +``` + +[Previous content remains the same until Implementation Guidelines section, then update with:] + +## Implementation Guidelines + +### 1. Laravel Compatibility +```dart +// Follow Laravel patterns where possible +class ServiceProvider { + void register() { + // Register services like Laravel + container.singleton((c) => ServiceImpl()); + + // Use contextual binding + container.when(PhotoController) + .needs() + .give(LocalStorage()); + + // Use tagged bindings + container.tag([ + EmailNotifier, + SmsNotifier + ], 'notifications'); + } +} +``` + +### 2. Testing Approach +```dart +// Follow Laravel testing patterns +void main() { + group('Feature Tests', () { + late TestCase test; + + setUp(() { + test = await TestCase.make(); + }); + + test('user can register', () async { + await test + .post('/register', { + 'name': 'John Doe', + 'email': 'john@example.com', + 'password': 'password' + }) + .assertStatus(302) + .assertRedirect('/home'); + }); + }); +} +``` + +[Previous content remains the same until Getting Help section, then update with:] + +## Getting Help + +1. **Documentation** + - Start with [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) + - Review [Foundation Integration Guide](foundation_integration_guide.md) + - Check [Testing Guide](testing_guide.md) + - Read package-specific documentation + +2. **Development Setup** + - Follow [Melos Configuration](melos_config.md) + - Setup development environment + - Run example applications + +3. **Resources** + - [Laravel Documentation](https://laravel.com/docs) + - [Dart Documentation](https://dart.dev/guides) + - [Package Layout](https://dart.dev/tools/pub/package-layout) + +[Rest of the file remains the same] diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f4ac1a6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,234 @@ +# Framework Documentation + +## Core Documentation + +### Getting Started +1. [Getting Started Guide](getting_started.md) +2. [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. [Foundation Integration Guide](foundation_integration_guide.md) +4. [Testing Guide](testing_guide.md) +5. [Package Integration Map](package_integration_map.md) + +### Core Architecture +1. [Core Architecture](core_architecture.md) + - System design + - Architectural patterns + - Extension points + - Package integration + +## Package Documentation + +### Core Framework +1. Core Package + - [Core Package Specification](core_package_specification.md) + - [Core Architecture](core_architecture.md) + +2. Container Package + - [Container Package Specification](container_package_specification.md) + - [Container Gap Analysis](container_gap_analysis.md) + - [Container Feature Integration](container_feature_integration.md) + - [Container Migration Guide](container_migration_guide.md) + +3. Contracts Package + - [Contracts Package Specification](contracts_package_specification.md) + +4. Events Package + - [Events Package Specification](events_package_specification.md) + - [Events Gap Analysis](events_gap_analysis.md) + +5. Pipeline Package + - [Pipeline Package Specification](pipeline_package_specification.md) + - [Pipeline Gap Analysis](pipeline_gap_analysis.md) + +6. Support Package + - [Support Package Specification](support_package_specification.md) + +### Infrastructure +1. Bus Package + - [Bus Package Specification](bus_package_specification.md) + - [Bus Gap Analysis](bus_gap_analysis.md) + +2. Config Package + - [Config Package Specification](config_package_specification.md) + - [Config Gap Analysis](config_gap_analysis.md) + +3. Filesystem Package + - [Filesystem Package Specification](filesystem_package_specification.md) + - [Filesystem Gap Analysis](filesystem_gap_analysis.md) + +4. Model Package + - [Model Package Specification](model_package_specification.md) + - [Model Gap Analysis](model_gap_analysis.md) + +5. Process Package + - [Process Package Specification](process_package_specification.md) + - [Process Gap Analysis](process_gap_analysis.md) + +6. Queue Package + - [Queue Package Specification](queue_package_specification.md) + - [Queue Gap Analysis](queue_gap_analysis.md) + +7. Route Package + - [Route Package Specification](route_package_specification.md) + - [Route Gap Analysis](route_gap_analysis.md) + +8. Testing Package + - [Testing Package Specification](testing_package_specification.md) + - [Testing Gap Analysis](testing_gap_analysis.md) + +## Package Dependencies + +```mermaid +graph TD + Core[Core] --> Container[Container] + Core --> Events[Events] + Core --> Pipeline[Pipeline] + + Container --> Contracts[Contracts] + Events --> Container + Pipeline --> Container + + Bus[Bus] --> Events + Bus --> Queue[Queue] + + Config[Config] --> Container + + Filesystem[Filesystem] --> Container + + Model[Model] --> Events + Model --> Container + + Process[Process] --> Events + Process --> Queue + + Queue --> Events + Queue --> Container + + Route[Route] --> Pipeline + Route --> Container + + Testing[Testing] --> Container + Testing --> Events +``` + +## Implementation Status + +### Core Framework (90%) +- Core Package (95%) + * Application lifecycle ✓ + * Service providers ✓ + * HTTP kernel ✓ + * Console kernel ✓ + * Exception handling ✓ + * Needs: Performance optimizations + +- Container Package (90%) + * Basic DI ✓ + * Auto-wiring ✓ + * Service providers ✓ + * Needs: Contextual binding + +- Events Package (85%) + * Event dispatching ✓ + * Event subscribers ✓ + * Event broadcasting ✓ + * Needs: Event discovery + +### Infrastructure (80%) +- Bus Package (85%) + * Command dispatching ✓ + * Command queuing ✓ + * Needs: Command batching + +- Config Package (80%) + * Configuration repository ✓ + * Environment loading ✓ + * Needs: Config caching + +- Filesystem Package (75%) + * Local driver ✓ + * Cloud storage ✓ + * Needs: Streaming support + +- Model Package (80%) + * Basic ORM ✓ + * Relationships ✓ + * Needs: Model events + +- Process Package (85%) + * Process management ✓ + * Process pools ✓ + * Needs: Process monitoring + +- Queue Package (85%) + * Queue workers ✓ + * Job batching ✓ + * Needs: Rate limiting + +- Route Package (90%) + * Route registration ✓ + * Route matching ✓ + * Middleware ✓ + * Needs: Route caching + +- Testing Package (85%) + * HTTP testing ✓ + * Database testing ✓ + * Needs: Browser testing + +## Development Workflow + +1. **Starting Development** + ```bash + # Clone repository + git clone https://github.com/organization/framework.git + + # Install dependencies + dart pub get + + # Run tests + dart test + ``` + +2. **Development Process** + - Write tests first + - Implement features + - Update documentation + - Submit PR + +3. **Quality Checks** + - Run tests + - Check code style + - Verify documentation + - Review performance + +## Contributing + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for detailed contribution guidelines. + +### Quick Start +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Read relevant package documentation +4. Follow [Testing Guide](testing_guide.md) + +## Resources + +### Documentation +- [Laravel Documentation](https://laravel.com/docs) +- [Dart Documentation](https://dart.dev/guides) +- [Package Layout](https://dart.dev/tools/pub/package-layout) + +### Tools +- [Dart SDK](https://dart.dev/get-dart) +- [VS Code](https://code.visualstudio.com) +- [Git](https://git-scm.com) + +### Community +- GitHub Issues +- Discussion Forum +- Team Chat + +## License + +This framework is open-sourced software licensed under the [MIT license](../LICENSE). diff --git a/docs/laravel_compatibility_roadmap.md b/docs/laravel_compatibility_roadmap.md new file mode 100644 index 0000000..2d5e9a2 --- /dev/null +++ b/docs/laravel_compatibility_roadmap.md @@ -0,0 +1,251 @@ +# Laravel Compatibility Roadmap + +## Overview + +This document outlines our path to Laravel API compatibility while maintaining backward compatibility with existing code. It provides a comprehensive view of package dependencies, implementation status, and migration strategy. + +## Package Dependency Hierarchy + +### Level 0: Core Foundation +```mermaid +graph TD + Container[Container] --> Contracts[Contracts] + Support[Support] --> Container + Pipeline[Pipeline] --> Container +``` + +Core Dependencies: +- Container: Service container, dependency injection +- Contracts: Interfaces and contracts +- Support: Helper functions, utilities +- Pipeline: Pipeline pattern implementation + +### Level 1: Infrastructure +```mermaid +graph TD + Events[Events] --> Container + Events --> Support + Config[Config] --> Container + Config --> Support + FileSystem[FileSystem] --> Support + FileSystem --> Container +``` + +Infrastructure Dependencies: +- Events: Event dispatching system +- Config: Configuration management +- FileSystem: File system abstraction + +### Level 2: Core Services +```mermaid +graph TD + Cache[Cache] --> Events + Cache --> Container + Database[Database] --> Events + Database --> Container + Queue[Queue] --> Events + Queue --> Container + Queue --> Pipeline +``` + +Core Service Dependencies: +- Cache: Caching system +- Database: Database abstraction +- Queue: Queue system and job processing + +### Level 3: HTTP Layer +```mermaid +graph TD + Routing[Routing] --> Pipeline + Routing --> Container + Http[Http] --> Pipeline + Http --> Events + Session[Session] --> Cache + Session --> Events +``` + +HTTP Layer Dependencies: +- Routing: Route registration and matching +- Http: HTTP request/response handling +- Session: Session management + +## Current Implementation Status + +[Previous implementation status section remains the same] + +## Success Metrics + +### 1. API Compatibility +```yaml +Required: +- 100% Laravel interface implementation +- All Laravel patterns supported +- Full feature parity +- Backward compatibility maintained +``` + +### 2. Performance +```yaml +Targets: +- Resolution: < 0.1ms per operation +- Memory: < 10MB overhead +- Cache hit rate: > 90% +- Startup time: < 100ms +``` + +### 3. Code Quality +```yaml +Requirements: +- 100% test coverage +- Static analysis passing +- Documentation complete +- Examples provided +``` + +### 4. Integration +```yaml +Verification: +- Cross-package tests passing +- Performance benchmarks met +- Real-world examples working +- Migration guides verified +``` + +## Key Design Decisions + +### 1. Backward Compatibility +```dart +// Maintain existing APIs +class Container { + // Existing methods stay the same + T make(); + void bind(T instance); + + // New methods add functionality + ContextualBindingBuilder when(Type concrete); + void tag(List types, String tag); +} +``` + +### 2. Laravel Compatibility +```dart +// Match Laravel's patterns +container.when(UserController) + .needs() + .give((c) => SpecialService()); + +container.tag([ServiceA, ServiceB], 'services'); + +container.call(instance, 'method', parameters); +``` + +### 3. Performance Focus +```dart +// Add caching +class Container { + final ResolutionCache _cache; + final ReflectionCache _reflectionCache; + + T make([dynamic context]) { + return _cache.get(context) ?? _resolve(context); + } +} +``` + +## Implementation Strategy + +[Previous implementation strategy section remains the same] + +## Integration Considerations + +### 1. Service Provider Pattern +- Registration phase +- Boot phase +- Deferred providers + +### 2. Event System +- Synchronous events +- Queued events +- Event subscribers + +### 3. Queue System +- Multiple drivers +- Job handling +- Failed jobs + +### 4. Database Layer +- Query builder +- Schema builder +- Migrations + +### 5. HTTP Layer +- Middleware +- Controllers +- Resources + +### 6. Authentication +- Guards +- Providers +- Policies + +## Getting Started + +### 1. Development Environment +```bash +# Clone repository +git clone https://github.com/org/platform.git + +# Install dependencies +dart pub get + +# Run tests +dart test +``` + +### 2. Package Development +```yaml +1. Choose package level: + - Level 0: Foundation packages + - Level 1: Infrastructure packages + - Level 2: Core services + - Level 3: HTTP layer + +2. Review dependencies: + - Check required packages + - Verify integration points + - Plan implementation + +3. Follow implementation order: + - Core functionality + - Laravel compatibility + - Tests and documentation +``` + +### 3. Quality Assurance +```yaml +1. Testing: + - Unit tests + - Integration tests + - Performance tests + - Compatibility tests + +2. Documentation: + - API documentation + - Usage examples + - Integration guides + - Migration guides + +3. Performance: + - Benchmarking + - Profiling + - Optimization +``` + +## Next Steps + +[Previous next steps section remains the same] + +Would you like me to: +1. Create detailed plans for package creation? +2. Start implementing specific features? +3. Create test plans for new functionality? diff --git a/docs/model_gap_analysis.md b/docs/model_gap_analysis.md new file mode 100644 index 0000000..fc7e1a2 --- /dev/null +++ b/docs/model_gap_analysis.md @@ -0,0 +1,316 @@ +# Model Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Model package's actual implementation and Laravel's Eloquent functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Model Package Specification](model_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Model Scopes +class ModelScope { + // Need to implement: + Query apply(Query query); + bool shouldApply(Query query); +} + +// 2. Model Observers +class ModelObserver { + // Need to implement: + void creating(T model); + void created(T model); + void updating(T model); + void updated(T model); + void deleting(T model); + void deleted(T model); + void restored(T model); + void forceDeleted(T model); +} + +// 3. Model Factories +class ModelFactory { + // Need to implement: + T definition(); + T make([Map? attributes]); + Future create([Map? attributes]); + List makeMany(int count, [Map? attributes]); + Future> createMany(int count, [Map? attributes]); +} +``` + +### 2. Missing Relationship Types +```dart +// Need to implement: + +// 1. Many to Many +class BelongsToMany extends Relationship { + // Need to implement: + String get table; + String get foreignPivotKey; + String get relatedPivotKey; + List get pivotColumns; + + Future> get(); + Future attach(List ids, [Map? attributes]); + Future detach(List? ids); + Future sync(List ids); + Future toggle(List ids); + Future updateExistingPivot(dynamic id, Map attributes); +} + +// 2. Has Many Through +class HasManyThrough extends Relationship { + // Need to implement: + String get through; + String get firstKey; + String get secondKey; + String get localKey; + String get secondLocalKey; + + Future> get(); +} + +// 3. Polymorphic Relations +class MorphTo extends Relationship { + // Need to implement: + String get morphType; + String get morphId; + + Future get(); +} +``` + +### 3. Missing Query Features +```dart +// Need to implement: + +// 1. Advanced Where Clauses +class Query { + // Need to implement: + Query whereIn(String column, List values); + Query whereNotIn(String column, List values); + Query whereBetween(String column, List values); + Query whereNotBetween(String column, List values); + Query whereNull(String column); + Query whereNotNull(String column); + Query whereDate(String column, DateTime date); + Query whereMonth(String column, int month); + Query whereYear(String column, int year); + Query whereTime(String column, String operator, DateTime time); +} + +// 2. Joins +class Query { + // Need to implement: + Query join(String table, String first, [String? operator, String? second]); + Query leftJoin(String table, String first, [String? operator, String? second]); + Query rightJoin(String table, String first, [String? operator, String? second]); + Query crossJoin(String table); +} + +// 3. Aggregates +class Query { + // Need to implement: + Future count([String column = '*']); + Future max(String column); + Future min(String column); + Future avg(String column); + Future sum(String column); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Applies a scope to the query. +/// +/// Example: +/// ```dart +/// class PublishedScope implements Scope { +/// Query apply(Query query) { +/// return query.where('published', true); +/// } +/// } +/// ``` +void addGlobalScope(Scope scope); + +/// Defines a local scope. +/// +/// Example: +/// ```dart +/// Query published() { +/// return where('published', true); +/// } +/// ``` +void scopePublished(Query query); +``` + +### 2. Missing Integration Examples +```dart +// Need examples for: + +// 1. Model Observers +class UserObserver extends ModelObserver { + @override + void created(User user) { + // Send welcome email + } + + @override + void deleted(User user) { + // Cleanup user data + } +} + +// 2. Model Factories +class UserFactory extends ModelFactory { + @override + User definition() { + return User() + ..name = faker.person.name() + ..email = faker.internet.email(); + } +} + +// 3. Many to Many Relationships +class User extends Model { + Future> roles() { + return belongsToMany('role_user') + .withPivot(['expires_at']) + .wherePivot('active', true) + .get(); + } +} +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Model Scopes', () { + test('applies global scopes', () async { + var posts = await Post.all(); + expect(posts.every((p) => p.published), isTrue); + }); + + test('applies local scopes', () async { + var posts = await Post().recent().popular().get(); + expect(posts, hasLength(greaterThan(0))); + }); + }); + + group('Model Factories', () { + test('creates model instances', () async { + var users = await UserFactory().createMany(3); + expect(users, hasLength(3)); + expect(users.first.name, isNotEmpty); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Model scopes (Laravel compatibility) + - Model observers (Laravel compatibility) + - Many to Many relationships + +2. **Medium Priority** + - Model factories + - Advanced where clauses + - Query joins + +3. **Low Priority** + - Additional relationship types + - Additional query features + - Performance optimizations + +## Next Steps + +1. **Implementation Tasks** + - Add model scopes + - Add model observers + - Add many to many relationships + - Add model factories + +2. **Documentation Tasks** + - Document model scopes + - Document model observers + - Document relationships + - Add integration examples + +3. **Testing Tasks** + - Add scope tests + - Add observer tests + - Add relationship tests + - Add factory tests + +## Development Guidelines + +### 1. Getting Started +Before implementing model features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Model Package Specification](model_package_specification.md) + +### 2. Implementation Process +For each model feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Model Package Specification](model_package_specification.md) + +### 4. Integration Considerations +When implementing model features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Model system must: +1. Handle large datasets efficiently +2. Optimize relationship loading +3. Support eager loading +4. Cache query results +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Model tests must: +1. Cover all model operations +2. Test relationships +3. Verify events +4. Check query building +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Model documentation must: +1. Explain model patterns +2. Show relationship examples +3. Cover event handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/model_package_specification.md b/docs/model_package_specification.md new file mode 100644 index 0000000..9680cec --- /dev/null +++ b/docs/model_package_specification.md @@ -0,0 +1,486 @@ +# Model Package Specification + +## Overview + +The Model package provides a robust data modeling system that matches Laravel's Eloquent functionality. It supports active record pattern, relationships, attribute casting, serialization, and model events while leveraging Dart's type system. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for model events + +## Core Features + +### 1. Base Model + +```dart +/// Core model implementation +abstract class Model { + /// Model attributes + final Map _attributes = {}; + + /// Original attributes + final Map _original = {}; + + /// Changed attributes + final Set _changes = {}; + + /// Model constructor + Model([Map? attributes]) { + fill(attributes ?? {}); + } + + /// Gets table name + String get table; + + /// Gets primary key + String get primaryKey => 'id'; + + /// Gets fillable attributes + List get fillable => []; + + /// Gets guarded attributes + List get guarded => ['id']; + + /// Gets attribute value + dynamic operator [](String key) => getAttribute(key); + + /// Sets attribute value + operator []=(String key, dynamic value) => setAttribute(key, value); + + /// Gets an attribute + dynamic getAttribute(String key) { + return _attributes[key]; + } + + /// Sets an attribute + void setAttribute(String key, dynamic value) { + if (!_original.containsKey(key)) { + _original[key] = _attributes[key]; + } + + _attributes[key] = value; + _changes.add(key); + } + + /// Fills attributes + void fill(Map attributes) { + for (var key in attributes.keys) { + if (_isFillable(key)) { + this[key] = attributes[key]; + } + } + } + + /// Checks if attribute is fillable + bool _isFillable(String key) { + if (guarded.contains(key)) return false; + if (fillable.isEmpty) return true; + return fillable.contains(key); + } + + /// Gets changed attributes + Map getDirty() { + var dirty = {}; + for (var key in _changes) { + dirty[key] = _attributes[key]; + } + return dirty; + } + + /// Checks if model is dirty + bool get isDirty => _changes.isNotEmpty; + + /// Gets original attributes + Map getOriginal() => Map.from(_original); + + /// Resets changes + void syncOriginal() { + _original.clear(); + _original.addAll(_attributes); + _changes.clear(); + } + + /// Converts to map + Map toMap() => Map.from(_attributes); + + /// Converts to JSON + String toJson() => jsonEncode(toMap()); +} +``` + +### 2. Model Relationships + +```dart +/// Has one relationship +class HasOne extends Relationship { + /// Foreign key + final String foreignKey; + + /// Local key + final String localKey; + + HasOne(Query query, Model parent, this.foreignKey, this.localKey) + : super(query, parent); + + @override + Future get() async { + return await query + .where(foreignKey, parent[localKey]) + .first(); + } +} + +/// Has many relationship +class HasMany extends Relationship { + /// Foreign key + final String foreignKey; + + /// Local key + final String localKey; + + HasMany(Query query, Model parent, this.foreignKey, this.localKey) + : super(query, parent); + + @override + Future> get() async { + return await query + .where(foreignKey, parent[localKey]) + .get(); + } +} + +/// Belongs to relationship +class BelongsTo extends Relationship { + /// Foreign key + final String foreignKey; + + /// Owner key + final String ownerKey; + + BelongsTo(Query query, Model child, this.foreignKey, this.ownerKey) + : super(query, child); + + @override + Future get() async { + return await query + .where(ownerKey, parent[foreignKey]) + .first(); + } +} +``` + +### 3. Model Events + +```dart +/// Model events mixin +mixin ModelEvents { + /// Event dispatcher + static EventDispatcherContract? _dispatcher; + + /// Sets event dispatcher + static void setEventDispatcher(EventDispatcherContract dispatcher) { + _dispatcher = dispatcher; + } + + /// Fires a model event + Future fireModelEvent(String event) async { + if (_dispatcher == null) return true; + + var result = await _dispatcher!.dispatch('model.$event', this); + return result != false; + } + + /// Fires creating event + Future fireCreatingEvent() => fireModelEvent('creating'); + + /// Fires created event + Future fireCreatedEvent() => fireModelEvent('created'); + + /// Fires updating event + Future fireUpdatingEvent() => fireModelEvent('updating'); + + /// Fires updated event + Future fireUpdatedEvent() => fireModelEvent('updated'); + + /// Fires deleting event + Future fireDeletingEvent() => fireModelEvent('deleting'); + + /// Fires deleted event + Future fireDeletedEvent() => fireModelEvent('deleted'); +} +``` + +### 4. Model Query Builder + +```dart +/// Model query builder +class Query { + /// Database connection + final DatabaseConnection _connection; + + /// Model instance + final T _model; + + /// Query constraints + final List _wheres = []; + final List _bindings = []; + final List _orders = []; + int? _limit; + int? _offset; + + Query(this._connection, this._model); + + /// Adds where clause + Query where(String column, [dynamic value]) { + _wheres.add('$column = ?'); + _bindings.add(value); + return this; + } + + /// Adds order by clause + Query orderBy(String column, [String direction = 'asc']) { + _orders.add('$column $direction'); + return this; + } + + /// Sets limit + Query limit(int limit) { + _limit = limit; + return this; + } + + /// Sets offset + Query offset(int offset) { + _offset = offset; + return this; + } + + /// Gets first result + Future first() async { + var results = await get(); + return results.isEmpty ? null : results.first; + } + + /// Gets results + Future> get() async { + var sql = _toSql(); + var rows = await _connection.select(sql, _bindings); + return rows.map((row) => _hydrate(row)).toList(); + } + + /// Builds SQL query + String _toSql() { + var sql = 'select * from ${_model.table}'; + + if (_wheres.isNotEmpty) { + sql += ' where ${_wheres.join(' and ')}'; + } + + if (_orders.isNotEmpty) { + sql += ' order by ${_orders.join(', ')}'; + } + + if (_limit != null) { + sql += ' limit $_limit'; + } + + if (_offset != null) { + sql += ' offset $_offset'; + } + + return sql; + } + + /// Hydrates model from row + T _hydrate(Map row) { + var instance = _model.newInstance() as T; + instance.fill(row); + instance.syncOriginal(); + return instance; + } +} +``` + +## Integration Examples + +### 1. Basic Model Usage +```dart +// Define model +class User extends Model { + @override + String get table => 'users'; + + @override + List get fillable => ['name', 'email']; + + String get name => this['name']; + set name(String value) => this['name'] = value; + + String get email => this['email']; + set email(String value) => this['email'] = value; +} + +// Create user +var user = User() + ..name = 'John Doe' + ..email = 'john@example.com'; + +await user.save(); + +// Find user +var found = await User.find(1); +print(found.name); // John Doe +``` + +### 2. Relationships +```dart +class User extends Model { + // Has many posts + Future> posts() { + return hasMany('user_id').get(); + } + + // Has one profile + Future profile() { + return hasOne('user_id').get(); + } +} + +class Post extends Model { + // Belongs to user + Future user() { + return belongsTo('user_id').get(); + } +} + +// Use relationships +var user = await User.find(1); +var posts = await user.posts(); +var profile = await user.profile(); +``` + +### 3. Events +```dart +// Register event listener +Model.getEventDispatcher().listen>((event) { + var user = event.model; + print('User ${user.name} was created'); +}); + +// Create user (triggers event) +var user = User() + ..name = 'Jane Doe' + ..email = 'jane@example.com'; + +await user.save(); +``` + +## Testing + +```dart +void main() { + group('Model', () { + test('handles attributes', () { + var user = User() + ..name = 'John' + ..email = 'john@example.com'; + + expect(user.name, equals('John')); + expect(user.isDirty, isTrue); + expect(user.getDirty(), containsPair('name', 'John')); + }); + + test('tracks changes', () { + var user = User() + ..fill({ + 'name': 'John', + 'email': 'john@example.com' + }); + + user.syncOriginal(); + user.name = 'Jane'; + + expect(user.isDirty, isTrue); + expect(user.getOriginal()['name'], equals('John')); + expect(user.name, equals('Jane')); + }); + }); + + group('Relationships', () { + test('loads relationships', () async { + var user = await User.find(1); + var posts = await user.posts(); + + expect(posts, hasLength(greaterThan(0))); + expect(posts.first, isA()); + }); + }); +} +``` + +## Next Steps + +1. Implement core model features +2. Add relationship types +3. Add model events +4. Add query builder +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing model features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Events Package Specification](events_package_specification.md) + +### 2. Implementation Process +For each model feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support model events (see [Events Package Specification](events_package_specification.md)) + +### 4. Integration Considerations +When implementing model features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Model system must: +1. Handle large datasets efficiently +2. Optimize relationship loading +3. Support eager loading +4. Cache query results +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Model tests must: +1. Cover all model operations +2. Test relationships +3. Verify events +4. Check query building +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Model documentation must: +1. Explain model patterns +2. Show relationship examples +3. Cover event handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/package_integration_map.md b/docs/package_integration_map.md new file mode 100644 index 0000000..52b3997 --- /dev/null +++ b/docs/package_integration_map.md @@ -0,0 +1,540 @@ +# Package Integration Map + +## Overview + +This document maps out the integration points between our framework packages and outlines how to maintain and enhance these integrations while achieving Laravel API compatibility. + +> **Related Documentation** +> - See [Core Architecture](core_architecture.md) for system design +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches + +## Package Documentation + +### Core Framework +1. Core Package + - [Core Package Specification](core_package_specification.md) + - [Core Architecture](core_architecture.md) + +2. Container Package + - [Container Package Specification](container_package_specification.md) + - [Container Gap Analysis](container_gap_analysis.md) + - [Container Feature Integration](container_feature_integration.md) + - [Container Migration Guide](container_migration_guide.md) + +3. Contracts Package + - [Contracts Package Specification](contracts_package_specification.md) + +4. Events Package + - [Events Package Specification](events_package_specification.md) + - [Events Gap Analysis](events_gap_analysis.md) + +5. Pipeline Package + - [Pipeline Package Specification](pipeline_package_specification.md) + - [Pipeline Gap Analysis](pipeline_gap_analysis.md) + +6. Support Package + - [Support Package Specification](support_package_specification.md) + +### Infrastructure +1. Bus Package + - [Bus Package Specification](bus_package_specification.md) + - [Bus Gap Analysis](bus_gap_analysis.md) + +2. Config Package + - [Config Package Specification](config_package_specification.md) + - [Config Gap Analysis](config_gap_analysis.md) + +3. Filesystem Package + - [Filesystem Package Specification](filesystem_package_specification.md) + - [Filesystem Gap Analysis](filesystem_gap_analysis.md) + +4. Model Package + - [Model Package Specification](model_package_specification.md) + - [Model Gap Analysis](model_gap_analysis.md) + +5. Process Package + - [Process Package Specification](process_package_specification.md) + - [Process Gap Analysis](process_gap_analysis.md) + +6. Queue Package + - [Queue Package Specification](queue_package_specification.md) + - [Queue Gap Analysis](queue_gap_analysis.md) + +7. Route Package + - [Route Package Specification](route_package_specification.md) + - [Route Gap Analysis](route_gap_analysis.md) + +8. Testing Package + - [Testing Package Specification](testing_package_specification.md) + - [Testing Gap Analysis](testing_gap_analysis.md) + +## Core Integration Points + +### 1. Container Integration Hub +```dart +// All packages integrate with Container for dependency injection +class ServiceProvider { + final Container container; + + // Current Integration + void register() { + container.registerSingleton(ServiceImpl()); + } + + // Laravel-Compatible Enhancement + void register() { + // Add contextual binding + container.when(Service).needs().give(FileLogger()); + + // Add tagged binding + container.tag([ + EmailNotifier, + SmsNotifier, + PushNotifier + ], 'notifications'); + } +} +``` + +### 2. Event System Integration +```dart +// Events package integrates with multiple packages +class EventServiceProvider { + // Current Integration + void register() { + // Queue Integration + container.singleton((c) => + QueuedEventDispatcher( + queue: c.make(), + broadcaster: c.make() + ) + ); + + // Bus Integration + container.singleton((c) => + CommandDispatcher( + events: c.make(), + queue: c.make() + ) + ); + } + + // Laravel-Compatible Enhancement + void register() { + // Add event discovery + container.singleton((c) => + EventDiscovery(c.make()) + ); + + // Add after commit handling + container.singleton((c) => + DatabaseEventDispatcher( + events: c.make(), + db: c.make() + ) + ); + } +} +``` + +### 3. Queue and Bus Integration +```dart +// Queue and Bus packages work together for job handling +class QueuedCommandDispatcher { + // Current Integration + Future dispatch(Command command) async { + if (command is ShouldQueue) { + await queue.push(QueuedCommandJob(command)); + } else { + await commandBus.dispatch(command); + } + } + + // Laravel-Compatible Enhancement + Future dispatch(Command command) async { + // Add job middleware + var job = QueuedCommandJob(command) + ..through([ + RateLimitedMiddleware(), + WithoutOverlappingMiddleware() + ]); + + // Add job batching + if (command is BatchableCommand) { + await queue.batch([job]) + .allowFailures() + .dispatch(); + } else { + await queue.push(job); + } + } +} +``` + +### 4. Route and Core Integration +```dart +// Route package integrates with Core for HTTP handling +class RouterServiceProvider { + // Current Integration + void register() { + container.singleton((c) => + Router() + ..use(LoggingMiddleware()) + ..use(AuthMiddleware()) + ); + } + + // Laravel-Compatible Enhancement + void register() { + // Add model binding + container.singleton((c) => + RouteModelBinder( + models: c.make(), + db: c.make() + ) + ); + + // Add subdomain routing + container.singleton((c) => + SubdomainRouter( + domains: c.make(), + router: c.make() + ) + ); + } +} +``` + +### 5. Process and Queue Integration +```dart +// Process package integrates with Queue for background tasks +class ProcessManager { + // Current Integration + Future runProcess(String command) async { + var process = await Process.start(command); + queue.push(ProcessMonitorJob(process.pid)); + } + + // Laravel-Compatible Enhancement + Future runProcess(String command) async { + // Add scheduling + scheduler.job(ProcessJob(command)) + .everyFiveMinutes() + .withoutOverlapping() + .onFailure((e) => notifyAdmin(e)); + + // Add process pools + processPool.job(command) + .onServers(['worker-1', 'worker-2']) + .dispatch(); + } +} +``` + +### 6. Model and Event Integration +```dart +// Model package integrates with Events for model events +class ModelEventDispatcher { + // Current Integration + Future save(Model model) async { + await events.dispatch(ModelSaving(model)); + await db.save(model); + await events.dispatch(ModelSaved(model)); + } + + // Laravel-Compatible Enhancement + Future save(Model model) async { + // Add transaction awareness + await db.transaction((tx) async { + await events.dispatch(ModelSaving(model)); + await tx.save(model); + + // Queue after commit + events.afterCommit(() => + events.dispatch(ModelSaved(model)) + ); + }); + } +} +``` + +## Package-Specific Integration Points + +### 1. Container Package +```dart +// Integration with other packages +class Container { + // Current + void bootstrap() { + registerSingleton(); + registerSingleton(); + registerSingleton(); + } + + // Enhanced + void bootstrap() { + // Add service repository + registerSingleton((c) => + ServiceRepository([ + EventServiceProvider(), + QueueServiceProvider(), + BusServiceProvider() + ]) + ); + + // Add deferred loading + registerDeferred((c) => + ReportGenerator(c.make()) + ); + } +} +``` + +### 2. Events Package +```dart +// Integration with other packages +class EventDispatcher { + // Current + Future dispatch(Event event) async { + if (event is QueuedEvent) { + await queue.push(event); + } else { + await notifyListeners(event); + } + } + + // Enhanced + Future dispatch(Event event) async { + // Add broadcast channels + if (event is BroadcastEvent) { + await broadcast.to(event.channels) + .with(['queue' => queue.connection()]) + .send(event); + } + + // Add event subscribers + await container.make() + .dispatch(event); + } +} +``` + +### 3. Queue Package +```dart +// Integration with other packages +class QueueManager { + // Current + Future process() async { + while (true) { + var job = await queue.pop(); + await job.handle(); + } + } + + // Enhanced + Future process() async { + // Add worker management + worker.supervise((worker) { + worker.process('default') + .throughMiddleware([ + RateLimited::class, + PreventOverlapping::class + ]) + .withEvents(events); + }); + } +} +``` + +## Integration Enhancement Strategy + +1. **Container Enhancements** + - Add contextual binding + - Add tagged bindings + - Keep existing integrations working + +2. **Event System Enhancements** + - Add event discovery + - Add after commit handling + - Maintain existing event flow + +3. **Queue System Enhancements** + - Add job batching + - Add better job middleware + - Keep existing job handling + +4. **Route System Enhancements** + - Add model binding + - Add subdomain routing + - Maintain existing routing + +5. **Process System Enhancements** + - Add scheduling + - Add process pools + - Keep existing process management + +6. **Model System Enhancements** + - Add Eloquent features + - Add relationships + - Maintain existing model events + +## Implementation Steps + +1. **Document Current Integration Points** + - Map all package dependencies + - Document integration interfaces + - Note existing functionality + +2. **Plan Laravel-Compatible Interfaces** + - Review Laravel's interfaces + - Design compatible interfaces + - Plan migration strategy + +3. **Implement Enhancements** + - Start with Container enhancements + - Add Event enhancements + - Add Queue enhancements + - Continue with other packages + +4. **Test Integration Points** + - Test existing functionality + - Test new functionality + - Test Laravel compatibility + +5. **Migration Guide** + - Document breaking changes + - Provide upgrade path + - Include examples + +## Package Dependencies + +```mermaid +graph TD + Core[Core] --> Container[Container] + Core --> Events[Events] + Core --> Pipeline[Pipeline] + + Container --> Contracts[Contracts] + Events --> Container + Pipeline --> Container + + Bus[Bus] --> Events + Bus --> Queue[Queue] + + Config[Config] --> Container + + Filesystem[Filesystem] --> Container + + Model[Model] --> Events + Model --> Container + + Process[Process] --> Events + Process --> Queue + + Queue --> Events + Queue --> Container + + Route[Route] --> Pipeline + Route --> Container + + Testing[Testing] --> Container + Testing --> Events +``` + +## Implementation Status + +### Core Framework (90%) +- Core Package (95%) + * Application lifecycle ✓ + * Service providers ✓ + * HTTP kernel ✓ + * Console kernel ✓ + * Exception handling ✓ + * Needs: Performance optimizations + +- Container Package (90%) + * Basic DI ✓ + * Auto-wiring ✓ + * Service providers ✓ + * Needs: Contextual binding + +- Events Package (85%) + * Event dispatching ✓ + * Event subscribers ✓ + * Event broadcasting ✓ + * Needs: Event discovery + +### Infrastructure (80%) +- Bus Package (85%) + * Command dispatching ✓ + * Command queuing ✓ + * Needs: Command batching + +- Config Package (80%) + * Configuration repository ✓ + * Environment loading ✓ + * Needs: Config caching + +- Filesystem Package (75%) + * Local driver ✓ + * Cloud storage ✓ + * Needs: Streaming support + +- Model Package (80%) + * Basic ORM ✓ + * Relationships ✓ + * Needs: Model events + +- Process Package (85%) + * Process management ✓ + * Process pools ✓ + * Needs: Process monitoring + +- Queue Package (85%) + * Queue workers ✓ + * Job batching ✓ + * Needs: Rate limiting + +- Route Package (90%) + * Route registration ✓ + * Route matching ✓ + * Middleware ✓ + * Needs: Route caching + +- Testing Package (85%) + * HTTP testing ✓ + * Database testing ✓ + * Needs: Browser testing + +## Development Guidelines + +### 1. Getting Started +Before implementing integrations: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) + +### 2. Implementation Process +For each integration: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All integrations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match package specifications + +### 4. Documentation Requirements +Integration documentation must: +1. Explain integration patterns +2. Show usage examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/pipeline_gap_analysis.md b/docs/pipeline_gap_analysis.md new file mode 100644 index 0000000..f7d15b5 --- /dev/null +++ b/docs/pipeline_gap_analysis.md @@ -0,0 +1,316 @@ +# Pipeline Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Pipeline package's actual implementation and our documentation, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Pipeline Package Specification](pipeline_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Pipeline Hub +class PipelineHub { + // Need to implement: + Pipeline pipeline(String name); + void defaults(List pipes); + Pipeline middleware(); + Pipeline bus(); +} + +// 2. Pipeline Conditions +class Pipeline { + // Need to implement: + Pipeline when(bool Function() callback); + Pipeline unless(bool Function() callback); + Pipeline whenCallback(Function callback); +} + +// 3. Pipeline Caching +class Pipeline { + // Need to implement: + void enableCache(); + void clearCache(); + dynamic getCached(String key); +} +``` + +### 2. Existing Features Not Documented + +```dart +// Implemented but not documented: + +// 1. Type Registration +class Pipeline { + /// Registers pipe types for string resolution + void registerPipeType(String name, Type type); + + /// Type map for string resolution + final Map _typeMap = {}; +} + +// 2. Method Invocation +class Pipeline { + /// Invokes methods on pipe instances + Future invokeMethod( + dynamic instance, + String methodName, + List arguments + ); + + /// Sets method to call on pipes + Pipeline via(String method); +} + +// 3. Exception Handling +class Pipeline { + /// Logger for pipeline + final Logger _logger; + + /// Handles exceptions in pipeline + dynamic handleException(dynamic passable, Object e); +} +``` + +### 3. Integration Points Not Documented + +```dart +// 1. Container Integration +class Pipeline { + /// Container reference + final Container? _container; + + /// Gets container instance + Container getContainer(); + + /// Sets container instance + Pipeline setContainer(Container container); +} + +// 2. Reflection Integration +class Pipeline { + /// Resolves pipe types using mirrors + Type? _resolvePipeType(String pipeClass) { + try { + for (var lib in currentMirrorSystem().libraries.values) { + // Reflection logic... + } + } catch (_) {} + } +} + +// 3. Logging Integration +class Pipeline { + /// Logger instance + final Logger _logger; + + /// Logs pipeline events + void _logPipelineEvent(String message, [Object? error]); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation + +```dart +// Need to document: + +/// Registers a pipe type for string resolution. +/// +/// This allows pipes to be specified by string names in the through() method. +/// +/// Example: +/// ```dart +/// pipeline.registerPipeType('auth', AuthMiddleware); +/// pipeline.through(['auth']); +/// ``` +void registerPipeType(String name, Type type); + +/// Sets the method to be called on pipe instances. +/// +/// By default, the 'handle' method is called. This method allows +/// customizing which method is called on each pipe. +/// +/// Example: +/// ```dart +/// pipeline.via('process').through([MyPipe]); +/// // Will call process() instead of handle() +/// ``` +Pipeline via(String method); +``` + +### 2. Missing Integration Examples + +```dart +// Need examples for: + +// 1. Container Integration +var pipeline = Pipeline(container) + ..through([ + AuthMiddleware, + container.make(), + 'validation' // Resolved from container + ]); + +// 2. Exception Handling +pipeline.through([ + (passable, next) async { + try { + return await next(passable); + } catch (e) { + logger.error('Pipeline error', e); + throw PipelineException(e.toString()); + } + } +]); + +// 3. Method Customization +pipeline.via('process') + .through([ + ProcessingPipe(), // Will call process() instead of handle() + ValidationPipe() + ]); +``` + +### 3. Missing Test Coverage + +```dart +// Need tests for: + +void main() { + group('Type Registration', () { + test('resolves string pipes to types', () { + var pipeline = Pipeline(container); + pipeline.registerPipeType('auth', AuthMiddleware); + + await pipeline + .through(['auth']) + .send(request) + .then(handler); + + verify(() => container.make()).called(1); + }); + }); + + group('Method Invocation', () { + test('calls custom methods on pipes', () { + var pipeline = Pipeline(container); + var pipe = MockPipe(); + + await pipeline + .via('process') + .through([pipe]) + .send(data) + .then(handler); + + verify(() => pipe.process(any, any)).called(1); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Pipeline hub (Laravel compatibility) + - Pipeline conditions (Laravel compatibility) + - Better exception handling + +2. **Medium Priority** + - Pipeline caching + - Better type resolution + - Performance optimizations + +3. **Low Priority** + - Additional helper methods + - Extended testing utilities + - Debug/profiling tools + +## Next Steps + +1. **Implementation Tasks** + - Add pipeline hub + - Add pipeline conditions + - Add caching support + - Improve exception handling + +2. **Documentation Tasks** + - Document type registration + - Document method invocation + - Document exception handling + - Add integration examples + +3. **Testing Tasks** + - Add type registration tests + - Add method invocation tests + - Add exception handling tests + - Add integration tests + +Would you like me to: +1. Start implementing missing features? +2. Update documentation for existing features? +3. Create test cases for missing coverage? + +## Development Guidelines + +### 1. Getting Started +Before implementing pipeline features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Pipeline Package Specification](pipeline_package_specification.md) + +### 2. Implementation Process +For each pipeline feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Pipeline Package Specification](pipeline_package_specification.md) + +### 4. Integration Considerations +When implementing pipeline features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Pipeline system must: +1. Handle nested pipelines efficiently +2. Minimize memory usage in long pipelines +3. Support async operations +4. Scale with number of stages +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Pipeline tests must: +1. Cover all pipeline types +2. Test stage ordering +3. Verify error handling +4. Check conditional execution +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Pipeline documentation must: +1. Explain pipeline patterns +2. Show integration examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/pipeline_package_specification.md b/docs/pipeline_package_specification.md new file mode 100644 index 0000000..48621b8 --- /dev/null +++ b/docs/pipeline_package_specification.md @@ -0,0 +1,408 @@ +# Pipeline Package Specification + +## Overview + +The Pipeline package provides a robust implementation of the pipeline pattern, allowing for the sequential processing of tasks through a series of stages. It integrates deeply with our Route, Bus, and Queue packages while maintaining Laravel compatibility. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Contracts Package Specification](contracts_package_specification.md) for pipeline contracts + +## Core Features + +### 1. Pipeline Base + +```dart +/// Core pipeline class with conditional execution +class Pipeline with Conditionable> { + final Container _container; + final List> _pipes; + TPassable? _passable; + String _method = 'handle'; + + Pipeline(this._container, [List>? pipes]) + : _pipes = pipes ?? []; + + /// Sends an object through the pipeline + Pipeline send(TPassable passable) { + _passable = passable; + return this; + } + + /// Sets the stages of the pipeline + Pipeline through(List pipes) { + for (var pipe in pipes) { + if (pipe is String) { + // Resolve from container + _pipes.add(_container.make(pipe)); + } else if (pipe is Type) { + _pipes.add(_container.make(pipe)); + } else if (pipe is Pipe) { + _pipes.add(pipe); + } else if (pipe is Function) { + _pipes.add(FunctionPipe(pipe)); + } + } + return this; + } + + /// Sets the method to call on the pipes + Pipeline via(String method) { + _method = method; + return this; + } + + /// Process the pipeline to final result + Future then( + FutureOr Function(TPassable) destination + ) async { + var pass = _passable; + if (pass == null) { + throw PipelineException('No passable object provided'); + } + + // Build pipeline + var pipeline = _pipes.fold( + destination, + (next, pipe) => (passable) => + _container.call(() => pipe.handle(passable, next)) + ); + + // Execute pipeline + return await pipeline(pass); + } +} +``` + +### 2. Middleware Pipeline + +```dart +/// HTTP middleware pipeline with route integration +class MiddlewarePipeline extends Pipeline { + final Router _router; + + MiddlewarePipeline(Container container, this._router) + : super(container); + + /// Adds route-specific middleware + MiddlewarePipeline throughRoute(Route route) { + // Get global middleware + var middleware = _router.middleware; + + // Add route middleware + if (route.middleware.isNotEmpty) { + middleware.addAll( + route.middleware.map((m) => _container.make(m)) + ); + } + + // Add route group middleware + if (route.group != null) { + middleware.addAll(route.group!.middleware); + } + + return through(middleware); + } + + /// Processes request through middleware + Future process( + Request request, + FutureOr Function(Request) destination + ) { + return send(request) + .when(() => shouldProcessMiddleware(request)) + .then(destination); + } + + /// Checks if middleware should be processed + bool shouldProcessMiddleware(Request request) { + return !request.attributes.containsKey('skip_middleware'); + } +} +``` + +### 3. Bus Pipeline + +```dart +/// Command bus pipeline with handler resolution +class BusPipeline extends Pipeline { + final CommandBus _bus; + + BusPipeline(Container container, this._bus) + : super(container); + + /// Processes command through pipeline + Future process( + TCommand command, + [Handler? handler] + ) { + // Resolve handler + handler ??= _resolveHandler(command); + + return send(command).then((cmd) => + handler!.handle(cmd) as Future + ); + } + + /// Resolves command handler + Handler _resolveHandler(TCommand command) { + if (command is Command) { + return _container.make(command.handler); + } + + var handlerType = _bus.handlers[TCommand]; + if (handlerType == null) { + throw HandlerNotFoundException( + 'No handler found for ${TCommand}' + ); + } + + return _container.make(handlerType); + } +} +``` + +### 4. Job Pipeline + +```dart +/// Queue job pipeline with middleware +class JobPipeline extends Pipeline { + final QueueManager _queue; + + JobPipeline(Container container, this._queue) + : super(container); + + /// Processes job through pipeline + Future process(Job job) { + return send(job) + .through(_queue.middleware) + .then((j) => j.handle()); + } + + /// Adds rate limiting + JobPipeline withRateLimit(int maxAttempts, Duration timeout) { + return through([ + RateLimitedPipe(maxAttempts, timeout) + ]); + } + + /// Prevents overlapping jobs + JobPipeline withoutOverlapping() { + return through([WithoutOverlappingPipe()]); + } +} +``` + +### 5. Pipeline Hub + +```dart +/// Manages application pipelines +class PipelineHub { + final Container _container; + final Map _pipelines = {}; + final List _defaults = []; + + PipelineHub(this._container); + + /// Gets or creates a pipeline + Pipeline pipeline(String name) { + return _pipelines.putIfAbsent( + name, + () => Pipeline(_container, [..._defaults]) + ); + } + + /// Gets middleware pipeline + MiddlewarePipeline middleware() { + return pipeline('middleware') as MiddlewarePipeline; + } + + /// Gets bus pipeline + BusPipeline bus() { + return pipeline('bus') as BusPipeline; + } + + /// Gets job pipeline + JobPipeline job() { + return pipeline('job') as JobPipeline; + } + + /// Sets default pipes + void defaults(List pipes) { + _defaults.addAll(pipes); + } +} +``` + +## Integration Examples + +### 1. Route Integration +```dart +// In RouteServiceProvider +void boot() { + router.middleware([ + StartSession::class, + VerifyCsrfToken::class + ]); + + router.group(['middleware' => ['auth']], () { + router.get('/dashboard', DashboardController); + }); +} + +// In Router +Future dispatch(Request request) { + var route = matchRoute(request); + + return container.make() + .throughRoute(route) + .process(request, (req) => route.handle(req)); +} +``` + +### 2. Command Bus Integration +```dart +// In CommandBus +Future dispatch(Command command) { + return container.make() + .through([ + TransactionPipe(), + ValidationPipe(), + AuthorizationPipe() + ]) + .process(command); +} + +// Usage +class CreateOrder implements Command { + @override + Type get handler => CreateOrderHandler; +} + +var order = await bus.dispatch( + CreateOrder(items: items) +); +``` + +### 3. Queue Integration +```dart +// In QueueWorker +Future process(Job job) { + return container.make() + .withRateLimit(3, Duration(minutes: 1)) + .withoutOverlapping() + .process(job); +} + +// Usage +class ProcessPayment implements Job { + @override + Future handle() async { + // Process payment + } +} + +await queue.push(ProcessPayment( + orderId: order.id +)); +``` + +## Testing + +```dart +void main() { + group('Middleware Pipeline', () { + test('processes route middleware', () async { + var pipeline = MiddlewarePipeline(container, router); + var route = Route('/test', middleware: ['auth']); + + var response = await pipeline + .throughRoute(route) + .process(request, handler); + + verify(() => auth.handle(any, any)).called(1); + }); + }); + + group('Bus Pipeline', () { + test('resolves and executes handler', () async { + var pipeline = BusPipeline(container, bus); + var command = CreateOrder(items: items); + + var result = await pipeline.process(command); + + expect(result, isA()); + verify(() => handler.handle(command)).called(1); + }); + }); +} +``` + +## Next Steps + +1. Add more middleware types +2. Enhance bus pipeline features +3. Add job pipeline features +4. Improve testing coverage +5. Add performance optimizations + +Would you like me to enhance any other package specifications? + +## Development Guidelines + +### 1. Getting Started +Before implementing pipeline features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Understand [Contracts Package Specification](contracts_package_specification.md) + +### 2. Implementation Process +For each pipeline feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Implement required contracts (see [Contracts Package Specification](contracts_package_specification.md)) + +### 4. Integration Considerations +When implementing pipelines: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) +5. Implement all contracts from [Contracts Package Specification](contracts_package_specification.md) + +### 5. Performance Guidelines +Pipeline system must: +1. Handle nested pipelines efficiently +2. Minimize memory usage in long pipelines +3. Support async operations +4. Scale with number of stages +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Pipeline tests must: +1. Cover all pipeline types +2. Test stage ordering +3. Verify error handling +4. Check conditional execution +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Pipeline documentation must: +1. Explain pipeline patterns +2. Show integration examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/process_gap_analysis.md b/docs/process_gap_analysis.md new file mode 100644 index 0000000..d474129 --- /dev/null +++ b/docs/process_gap_analysis.md @@ -0,0 +1,299 @@ +# Process Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Process package's actual implementation and Laravel's process functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Process Package Specification](process_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for process events +> - See [Queue Package Specification](queue_package_specification.md) for background processing + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Process Pipelines +class ProcessPipeline { + // Need to implement: + Future pipe(String command); + Future pipeThrough(List commands); + Future pipeInput(String input); + Future pipeOutput(String file); + Future pipeErrorOutput(String file); +} + +// 2. Process Scheduling +class ProcessScheduler { + // Need to implement: + void schedule(String command, String frequency); + void daily(String command, [String time = '00:00']); + void weekly(String command, [int day = 0]); + void monthly(String command, [int day = 1]); + void cron(String expression, String command); +} + +// 3. Process Monitoring +class ProcessMonitor { + // Need to implement: + Future isRunning(int pid); + Future getStats(int pid); + Future onExit(int pid, Function callback); + Future onOutput(int pid, Function callback); + Future onError(int pid, Function callback); +} +``` + +### 2. Missing Process Features +```dart +// Need to implement: + +// 1. Process Groups +class ProcessGroup { + // Need to implement: + Future start(); + Future stop(); + Future restart(); + Future> wait(); + Future signal(ProcessSignal signal); + bool isRunning(); +} + +// 2. Process Isolation +class ProcessIsolation { + // Need to implement: + void setUser(String user); + void setGroup(String group); + void setWorkingDirectory(String directory); + void setEnvironment(Map env); + void setResourceLimits(ResourceLimits limits); +} + +// 3. Process Recovery +class ProcessRecovery { + // Need to implement: + void onCrash(Function callback); + void onHang(Function callback); + void onHighMemory(Function callback); + void onHighCpu(Function callback); + void restart(); +} +``` + +### 3. Missing Integration Features +```dart +// Need to implement: + +// 1. Queue Integration +class QueuedProcess { + // Need to implement: + Future queue(String command); + Future laterOn(String queue, Duration delay, String command); + Future chain(List commands); + Future release(Duration delay); +} + +// 2. Event Integration +class ProcessEvents { + // Need to implement: + void beforeStart(Function callback); + void afterStart(Function callback); + void beforeStop(Function callback); + void afterStop(Function callback); + void onOutput(Function callback); + void onError(Function callback); +} + +// 3. Logging Integration +class ProcessLogging { + // Need to implement: + void enableLogging(); + void setLogFile(String path); + void setLogLevel(LogLevel level); + void rotateLog(); + void purgeOldLogs(Duration age); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Pipes process output. +/// +/// Example: +/// ```dart +/// await process +/// .pipe('sort') +/// .pipe('uniq') +/// .pipeOutput('output.txt'); +/// ``` +Future pipe(String command); + +/// Schedules process execution. +/// +/// Example: +/// ```dart +/// scheduler.daily('backup.sh', '02:00'); +/// scheduler.weekly('cleanup.sh', DateTime.sunday); +/// scheduler.cron('0 * * * *', 'hourly.sh'); +/// ``` +void schedule(String command, String frequency); +``` + +### 2. Missing Integration Examples +```dart +// Need examples for: + +// 1. Process Groups +var group = ProcessGroup(); +group.add('web-server', '--port=8080'); +group.add('worker', '--queue=default'); +await group.start(); + +// 2. Process Recovery +var recovery = ProcessRecovery(process); +recovery.onCrash(() async { + await notifyAdmin('Process crashed'); + await process.restart(); +}); + +// 3. Process Monitoring +var monitor = ProcessMonitor(process); +monitor.onHighMemory((usage) async { + await process.restart(); + await notifyAdmin('High memory usage: $usage'); +}); +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Process Pipelines', () { + test('pipes process output', () async { + var process = await manager.start('ls'); + var result = await process + .pipe('sort') + .pipe('uniq') + .pipeOutput('output.txt'); + + expect(result.exitCode, equals(0)); + expect(File('output.txt').existsSync(), isTrue); + }); + }); + + group('Process Scheduling', () { + test('schedules daily tasks', () async { + var scheduler = ProcessScheduler(); + scheduler.daily('backup.sh', '02:00'); + + var nextRun = scheduler.getNextRun('backup.sh'); + expect(nextRun.hour, equals(2)); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Process pipelines (Laravel compatibility) + - Process scheduling (Laravel compatibility) + - Process monitoring + +2. **Medium Priority** + - Process groups + - Process isolation + - Process recovery + +3. **Low Priority** + - Additional integration features + - Additional monitoring features + - Performance optimizations + +## Next Steps + +1. **Implementation Tasks** + - Add process pipelines + - Add process scheduling + - Add process monitoring + - Add process groups + +2. **Documentation Tasks** + - Document pipelines + - Document scheduling + - Document monitoring + - Add integration examples + +3. **Testing Tasks** + - Add pipeline tests + - Add scheduling tests + - Add monitoring tests + - Add group tests + +## Development Guidelines + +### 1. Getting Started +Before implementing process features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Process Package Specification](process_package_specification.md) +6. Review [Events Package Specification](events_package_specification.md) +7. Review [Queue Package Specification](queue_package_specification.md) + +### 2. Implementation Process +For each process feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Process Package Specification](process_package_specification.md) + +### 4. Integration Considerations +When implementing process features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Process system must: +1. Handle concurrent processes efficiently +2. Manage system resources +3. Support process pooling +4. Scale with process count +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Process tests must: +1. Cover all process operations +2. Test concurrent execution +3. Verify event handling +4. Check resource cleanup +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Process documentation must: +1. Explain process patterns +2. Show pipeline examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/process_package_specification.md b/docs/process_package_specification.md new file mode 100644 index 0000000..bf3c2f3 --- /dev/null +++ b/docs/process_package_specification.md @@ -0,0 +1,408 @@ +# Process Package Specification + +## Overview + +The Process package provides a robust system process handling system that matches Laravel's process functionality. It supports process execution, input/output handling, process pools, and signal handling while integrating with our Event and Queue packages. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for process events +> - See [Queue Package Specification](queue_package_specification.md) for background processing + +## Core Features + +### 1. Process Manager + +```dart +/// Core process manager implementation +class ProcessManager implements ProcessContract { + /// Container instance + final Container _container; + + /// Active processes + final Map _processes = {}; + + /// Process event dispatcher + final EventDispatcherContract _events; + + ProcessManager(this._container, this._events); + + /// Starts a process + Future start( + String command, [ + List? arguments, + ProcessOptions? options + ]) async { + options ??= ProcessOptions(); + + var process = await Process.start( + command, + arguments ?? [], + workingDirectory: options.workingDirectory, + environment: options.environment, + includeParentEnvironment: options.includeParentEnvironment, + runInShell: options.runInShell + ); + + _processes[process.pid] = process; + await _events.dispatch(ProcessStarted(process)); + + return process; + } + + /// Runs a process to completion + Future run( + String command, [ + List? arguments, + ProcessOptions? options + ]) async { + var process = await start(command, arguments, options); + var result = await process.exitCode; + + await _events.dispatch(ProcessCompleted( + process, + result + )); + + return ProcessResult( + process.pid, + result, + await _readOutput(process.stdout), + await _readOutput(process.stderr) + ); + } + + /// Kills a process + Future kill(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) async { + var process = _processes[pid]; + if (process == null) return; + + process.kill(signal); + await _events.dispatch(ProcessKilled(process)); + _processes.remove(pid); + } + + /// Gets active processes + List get activeProcesses => List.from(_processes.values); + + /// Reads process output + Future _readOutput(Stream> stream) async { + var buffer = StringBuffer(); + await for (var data in stream) { + buffer.write(String.fromCharCodes(data)); + } + return buffer.toString(); + } +} +``` + +### 2. Process Pool + +```dart +/// Process pool for parallel execution +class ProcessPool { + /// Maximum concurrent processes + final int concurrency; + + /// Process manager + final ProcessManager _manager; + + /// Active processes + final Set _active = {}; + + /// Pending commands + final Queue _pending = Queue(); + + ProcessPool(this._manager, {this.concurrency = 5}); + + /// Starts a process in the pool + Future start( + String command, [ + List? arguments, + ProcessOptions? options + ]) async { + var pending = PendingCommand( + command, + arguments, + options + ); + + _pending.add(pending); + await _processQueue(); + + return await pending.future; + } + + /// Processes pending commands + Future _processQueue() async { + while (_active.length < concurrency && _pending.isNotEmpty) { + var command = _pending.removeFirst(); + await _startProcess(command); + } + } + + /// Starts a process + Future _startProcess(PendingCommand command) async { + var process = await _manager.start( + command.command, + command.arguments, + command.options + ); + + _active.add(process); + + process.exitCode.then((result) { + _active.remove(process); + command.complete(ProcessResult( + process.pid, + result, + '', + '' + )); + _processQueue(); + }); + } +} +``` + +### 3. Process Events + +```dart +/// Process started event +class ProcessStarted { + /// The started process + final Process process; + + ProcessStarted(this.process); +} + +/// Process completed event +class ProcessCompleted { + /// The completed process + final Process process; + + /// Exit code + final int exitCode; + + ProcessCompleted(this.process, this.exitCode); +} + +/// Process killed event +class ProcessKilled { + /// The killed process + final Process process; + + ProcessKilled(this.process); +} + +/// Process failed event +class ProcessFailed { + /// The failed process + final Process process; + + /// Error details + final Object error; + + ProcessFailed(this.process, this.error); +} +``` + +### 4. Process Options + +```dart +/// Process execution options +class ProcessOptions { + /// Working directory + final String? workingDirectory; + + /// Environment variables + final Map? environment; + + /// Include parent environment + final bool includeParentEnvironment; + + /// Run in shell + final bool runInShell; + + /// Process timeout + final Duration? timeout; + + /// Idle timeout + final Duration? idleTimeout; + + /// Retry attempts + final int retryAttempts; + + /// Retry delay + final Duration retryDelay; + + ProcessOptions({ + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.timeout, + this.idleTimeout, + this.retryAttempts = 0, + this.retryDelay = const Duration(seconds: 1) + }); +} +``` + +## Integration Examples + +### 1. Basic Process Execution +```dart +// Run process +var result = await processManager.run('ls', ['-la']); +print('Output: ${result.stdout}'); +print('Exit code: ${result.exitCode}'); + +// Start long-running process +var process = await processManager.start('server', ['--port=8080']); +await process.exitCode; // Wait for completion +``` + +### 2. Process Pool +```dart +// Create process pool +var pool = ProcessPool(processManager, concurrency: 3); + +// Run multiple processes +await Future.wait([ + pool.start('task1'), + pool.start('task2'), + pool.start('task3'), + pool.start('task4') // Queued until slot available +]); +``` + +### 3. Process Events +```dart +// Listen for process events +events.listen((event) { + print('Process ${event.process.pid} started'); +}); + +events.listen((event) { + print('Process ${event.process.pid} completed with code ${event.exitCode}'); +}); + +// Start process +await processManager.start('long-task'); +``` + +## Testing + +```dart +void main() { + group('Process Manager', () { + test('runs processes', () async { + var manager = ProcessManager(container, events); + + var result = await manager.run('echo', ['Hello']); + + expect(result.exitCode, equals(0)); + expect(result.stdout, contains('Hello')); + }); + + test('handles process failure', () async { + var manager = ProcessManager(container, events); + + expect( + () => manager.run('invalid-command'), + throwsA(isA()) + ); + }); + }); + + group('Process Pool', () { + test('limits concurrent processes', () async { + var pool = ProcessPool(manager, concurrency: 2); + var started = []; + + events.listen((event) { + started.add(event.process.pid.toString()); + }); + + await Future.wait([ + pool.start('task1'), + pool.start('task2'), + pool.start('task3') + ]); + + expect(started.length, equals(3)); + expect(started.take(2).length, equals(2)); + }); + }); +} +``` + +## Next Steps + +1. Implement core process features +2. Add process pool +3. Add process events +4. Add retry handling +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing process features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Events Package Specification](events_package_specification.md) +6. Review [Queue Package Specification](queue_package_specification.md) + +### 2. Implementation Process +For each process feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support event integration (see [Events Package Specification](events_package_specification.md)) +5. Support queue integration (see [Queue Package Specification](queue_package_specification.md)) + +### 4. Integration Considerations +When implementing process features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Process system must: +1. Handle concurrent processes efficiently +2. Manage system resources +3. Support process pooling +4. Scale with process count +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Process tests must: +1. Cover all process operations +2. Test concurrent execution +3. Verify event handling +4. Check resource cleanup +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Process documentation must: +1. Explain process patterns +2. Show pool examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/queue_gap_analysis.md b/docs/queue_gap_analysis.md new file mode 100644 index 0000000..cc47d5f --- /dev/null +++ b/docs/queue_gap_analysis.md @@ -0,0 +1,301 @@ +# Queue Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Queue package's actual implementation and Laravel's queue functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Queue Package Specification](queue_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for event integration +> - See [Bus Package Specification](bus_package_specification.md) for command bus integration + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Job Middleware +class JobMiddleware { + // Need to implement: + Future handle(Job job, Function next); + Future withoutOverlapping(Job job, Function next); + Future rateLimit(Job job, Function next, int maxAttempts); + Future throttle(Job job, Function next, Duration duration); +} + +// 2. Job Events +class JobEvents { + // Need to implement: + void beforeJob(Job job); + void afterJob(Job job); + void failingJob(Job job, Exception exception); + void failedJob(Job job, Exception exception); + void retryingJob(Job job); + void retriedJob(Job job); +} + +// 3. Job Chaining +class JobChain { + // Need to implement: + void chain(List jobs); + void onConnection(String connection); + void onQueue(String queue); + void catch(Function(Exception) handler); + void finally(Function handler); +} +``` + +### 2. Missing Queue Features +```dart +// Need to implement: + +// 1. Queue Monitoring +class QueueMonitor { + // Need to implement: + Future> queueSizes(); + Future>> failedJobs(); + Future retryFailedJob(String id); + Future forgetFailedJob(String id); + Future pruneFailedJobs([Duration? older]); +} + +// 2. Queue Rate Limiting +class QueueRateLimiter { + // Need to implement: + Future tooManyAttempts(String key, int maxAttempts); + Future hit(String key, Duration decay); + Future clear(String key); + Future attempts(String key); + Future remaining(String key, int maxAttempts); +} + +// 3. Queue Batching +class QueueBatch { + // Need to implement: + Future then(Function handler); + Future catch(Function(Exception) handler); + Future finally(Function handler); + Future allowFailures(); + Future onConnection(String connection); + Future onQueue(String queue); +} +``` + +### 3. Missing Worker Features +```dart +// Need to implement: + +// 1. Worker Management +class WorkerManager { + // Need to implement: + Future scale(int processes); + Future pause(); + Future resume(); + Future restart(); + Future> status(); +} + +// 2. Worker Events +class WorkerEvents { + // Need to implement: + void workerStarting(); + void workerStopping(); + void workerStopped(); + void queueEmpty(String queue); + void looping(); +} + +// 3. Worker Options +class WorkerOptions { + // Need to implement: + Duration sleep; + Duration timeout; + int maxTries; + int maxJobs; + bool force; + bool stopWhenEmpty; + bool rest; +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Handles job middleware. +/// +/// Example: +/// ```dart +/// class RateLimitedJob extends Job with JobMiddleware { +/// @override +/// Future middleware(Function next) async { +/// return await rateLimit(5, Duration(minutes: 1), next); +/// } +/// } +/// ``` +Future middleware(Function next); + +/// Handles job events. +/// +/// Example: +/// ```dart +/// queue.beforeJob((job) { +/// print('Processing ${job.id}'); +/// }); +/// ``` +void beforeJob(Function(Job) callback); +``` + +### 2. Missing Integration Examples +```dart +// Need examples for: + +// 1. Job Chaining +var chain = queue.chain([ + ProcessPodcast(podcast), + OptimizeAudio(podcast), + NotifySubscribers(podcast) +]) +.onQueue('podcasts') +.catch((e) => handleError(e)) +.finally(() => cleanup()); + +// 2. Queue Monitoring +var monitor = QueueMonitor(queue); +var sizes = await monitor.queueSizes(); +print('Default queue size: ${sizes["default"]}'); + +// 3. Worker Management +var manager = WorkerManager(queue); +await manager.scale(4); // Scale to 4 processes +var status = await manager.status(); +print('Active workers: ${status.length}'); +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Job Middleware', () { + test('applies rate limiting', () async { + var job = RateLimitedJob(); + var limiter = MockRateLimiter(); + + await job.handle(); + + verify(() => limiter.tooManyAttempts(any, any)).called(1); + }); + }); + + group('Queue Monitoring', () { + test('monitors queue sizes', () async { + var monitor = QueueMonitor(queue); + var sizes = await monitor.queueSizes(); + + expect(sizes, containsPair('default', greaterThan(0))); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Job middleware (Laravel compatibility) + - Job events (Laravel compatibility) + - Queue monitoring + +2. **Medium Priority** + - Job chaining + - Queue rate limiting + - Worker management + +3. **Low Priority** + - Additional worker features + - Additional monitoring features + - Performance optimizations + +## Next Steps + +1. **Implementation Tasks** + - Add job middleware + - Add job events + - Add queue monitoring + - Add worker management + +2. **Documentation Tasks** + - Document job middleware + - Document job events + - Document monitoring + - Add integration examples + +3. **Testing Tasks** + - Add middleware tests + - Add event tests + - Add monitoring tests + - Add worker tests + +## Development Guidelines + +### 1. Getting Started +Before implementing queue features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Queue Package Specification](queue_package_specification.md) +6. Review [Events Package Specification](events_package_specification.md) +7. Review [Bus Package Specification](bus_package_specification.md) + +### 2. Implementation Process +For each queue feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Queue Package Specification](queue_package_specification.md) + +### 4. Integration Considerations +When implementing queue features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Queue system must: +1. Handle high job throughput +2. Process chains efficiently +3. Support concurrent workers +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Queue tests must: +1. Cover all queue operations +2. Test middleware behavior +3. Verify event handling +4. Check worker management +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Queue documentation must: +1. Explain queue patterns +2. Show middleware examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/queue_package_specification.md b/docs/queue_package_specification.md new file mode 100644 index 0000000..1e32454 --- /dev/null +++ b/docs/queue_package_specification.md @@ -0,0 +1,623 @@ +# Queue Package Specification + +## Overview + +The Queue package provides a robust job queueing system that matches Laravel's queue functionality. It supports multiple queue drivers, job retries, rate limiting, and job batching while integrating with our Event and Bus packages. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Events Package Specification](events_package_specification.md) for event integration +> - See [Bus Package Specification](bus_package_specification.md) for command bus integration + +## Core Features + +### 1. Queue Manager + +```dart +/// Core queue manager implementation +class QueueManager implements QueueContract { + /// Available queue connections + final Map _connections = {}; + + /// Default connection name + final String _defaultConnection; + + /// Configuration repository + final ConfigContract _config; + + QueueManager(this._config) + : _defaultConnection = _config.get('queue.default', 'sync'); + + @override + Future push(dynamic job, [String? queue]) async { + return await connection().push(job, queue); + } + + @override + Future later(Duration delay, dynamic job, [String? queue]) async { + return await connection().later(delay, job, queue); + } + + @override + Future pop([String? queue]) async { + return await connection().pop(queue); + } + + @override + QueueConnection connection([String? name]) { + name ??= _defaultConnection; + + return _connections.putIfAbsent(name, () { + var config = _getConfig(name!); + return _createConnection(config); + }); + } + + /// Creates a queue connection + QueueConnection _createConnection(Map config) { + switch (config['driver']) { + case 'sync': + return SyncConnection(config); + case 'database': + return DatabaseConnection(config); + case 'redis': + return RedisConnection(config); + case 'sqs': + return SqsConnection(config); + default: + throw UnsupportedError( + 'Unsupported queue driver: ${config["driver"]}' + ); + } + } + + /// Gets connection config + Map _getConfig(String name) { + var config = _config.get('queue.connections.$name'); + if (config == null) { + throw ArgumentError('Queue connection [$name] not configured.'); + } + return config; + } +} +``` + +### 2. Queue Connections + +```dart +/// Database queue connection +class DatabaseConnection implements QueueConnection { + /// Database connection + final DatabaseConnection _db; + + /// Table name + final String _table; + + DatabaseConnection(Map config) + : _db = DatabaseManager.connection(config['connection']), + _table = config['table'] ?? 'jobs'; + + @override + Future push(dynamic job, [String? queue]) async { + queue ??= 'default'; + var id = Uuid().v4(); + + await _db.table(_table).insert({ + 'id': id, + 'queue': queue, + 'payload': _serialize(job), + 'attempts': 0, + 'reserved_at': null, + 'available_at': DateTime.now(), + 'created_at': DateTime.now() + }); + + return id; + } + + @override + Future later(Duration delay, dynamic job, [String? queue]) async { + queue ??= 'default'; + var id = Uuid().v4(); + + await _db.table(_table).insert({ + 'id': id, + 'queue': queue, + 'payload': _serialize(job), + 'attempts': 0, + 'reserved_at': null, + 'available_at': DateTime.now().add(delay), + 'created_at': DateTime.now() + }); + + return id; + } + + @override + Future pop([String? queue]) async { + queue ??= 'default'; + + var job = await _db.transaction((tx) async { + var job = await tx.table(_table) + .where('queue', queue) + .whereNull('reserved_at') + .where('available_at', '<=', DateTime.now()) + .orderBy('id') + .first(); + + if (job != null) { + await tx.table(_table) + .where('id', job['id']) + .update({ + 'reserved_at': DateTime.now(), + 'attempts': job['attempts'] + 1 + }); + } + + return job; + }); + + if (job == null) return null; + + return DatabaseJob( + connection: this, + queue: queue, + job: job + ); + } + + /// Serializes job payload + String _serialize(dynamic job) { + return jsonEncode({ + 'type': job.runtimeType.toString(), + 'data': job.toMap() + }); + } +} + +/// Redis queue connection +class RedisConnection implements QueueConnection { + /// Redis client + final RedisClient _redis; + + /// Key prefix + final String _prefix; + + RedisConnection(Map config) + : _redis = RedisClient( + host: config['host'], + port: config['port'], + db: config['database'] + ), + _prefix = config['prefix'] ?? 'queues'; + + @override + Future push(dynamic job, [String? queue]) async { + queue ??= 'default'; + var id = Uuid().v4(); + + await _redis.rpush( + _getKey(queue), + _serialize(id, job) + ); + + return id; + } + + @override + Future later(Duration delay, dynamic job, [String? queue]) async { + queue ??= 'default'; + var id = Uuid().v4(); + + await _redis.zadd( + _getDelayedKey(queue), + DateTime.now().add(delay).millisecondsSinceEpoch.toDouble(), + _serialize(id, job) + ); + + return id; + } + + @override + Future pop([String? queue]) async { + queue ??= 'default'; + + // Move delayed jobs + var now = DateTime.now().millisecondsSinceEpoch; + var jobs = await _redis.zrangebyscore( + _getDelayedKey(queue), + '-inf', + now.toString() + ); + + for (var job in jobs) { + await _redis.rpush(_getKey(queue), job); + await _redis.zrem(_getDelayedKey(queue), job); + } + + // Get next job + var payload = await _redis.lpop(_getKey(queue)); + if (payload == null) return null; + + return RedisJob( + connection: this, + queue: queue, + payload: payload + ); + } + + /// Gets queue key + String _getKey(String queue) => '$_prefix:$queue'; + + /// Gets delayed queue key + String _getDelayedKey(String queue) => '$_prefix:$queue:delayed'; + + /// Serializes job payload + String _serialize(String id, dynamic job) { + return jsonEncode({ + 'id': id, + 'type': job.runtimeType.toString(), + 'data': job.toMap(), + 'attempts': 0 + }); + } +} +``` + +### 3. Queue Jobs + +```dart +/// Core job interface +abstract class Job { + /// Job ID + String get id; + + /// Job queue + String get queue; + + /// Number of attempts + int get attempts; + + /// Maximum tries + int get maxTries => 3; + + /// Timeout in seconds + int get timeout => 60; + + /// Executes the job + Future handle(); + + /// Releases the job back onto queue + Future release([Duration? delay]); + + /// Deletes the job + Future delete(); + + /// Fails the job + Future fail([Exception? exception]); +} + +/// Database job implementation +class DatabaseJob implements Job { + /// Database connection + final DatabaseConnection _connection; + + /// Job data + final Map _data; + + /// Job queue + @override + final String queue; + + DatabaseJob({ + required DatabaseConnection connection, + required String queue, + required Map job + }) : _connection = connection, + _data = job, + queue = queue; + + @override + String get id => _data['id']; + + @override + int get attempts => _data['attempts']; + + @override + Future handle() async { + var payload = jsonDecode(_data['payload']); + var job = _deserialize(payload); + await job.handle(); + } + + @override + Future release([Duration? delay]) async { + await _connection._db.table(_connection._table) + .where('id', id) + .update({ + 'reserved_at': null, + 'available_at': delay != null + ? DateTime.now().add(delay) + : DateTime.now() + }); + } + + @override + Future delete() async { + await _connection._db.table(_connection._table) + .where('id', id) + .delete(); + } + + @override + Future fail([Exception? exception]) async { + await _connection._db.table(_connection._table) + .where('id', id) + .update({ + 'failed_at': DateTime.now() + }); + + if (exception != null) { + await _connection._db.table('failed_jobs').insert({ + 'id': Uuid().v4(), + 'connection': _connection.name, + 'queue': queue, + 'payload': _data['payload'], + 'exception': exception.toString(), + 'failed_at': DateTime.now() + }); + } + } + + /// Deserializes job payload + dynamic _deserialize(Map payload) { + var type = payload['type']; + var data = payload['data']; + + return _connection._container.make(type) + ..fromMap(data); + } +} +``` + +### 4. Job Batching + +```dart +/// Job batch +class Batch { + /// Batch ID + final String id; + + /// Queue connection + final QueueConnection _connection; + + /// Jobs in batch + final List _jobs; + + /// Options + final BatchOptions _options; + + Batch(this.id, this._connection, this._jobs, this._options); + + /// Gets total jobs + int get totalJobs => _jobs.length; + + /// Gets pending jobs + Future get pendingJobs async { + return await _connection.table('job_batches') + .where('id', id) + .value('pending_jobs'); + } + + /// Gets failed jobs + Future get failedJobs async { + return await _connection.table('job_batches') + .where('id', id) + .value('failed_jobs'); + } + + /// Adds jobs to batch + Future add(List jobs) async { + _jobs.addAll(jobs); + + await _connection.table('job_batches') + .where('id', id) + .increment('total_jobs', jobs.length) + .increment('pending_jobs', jobs.length); + + for (var job in jobs) { + await _connection.push(job); + } + } + + /// Cancels the batch + Future cancel() async { + await _connection.table('job_batches') + .where('id', id) + .update({ + 'cancelled_at': DateTime.now() + }); + } + + /// Deletes the batch + Future delete() async { + await _connection.table('job_batches') + .where('id', id) + .delete(); + } +} +``` + +## Integration Examples + +### 1. Basic Queue Usage +```dart +// Define job +class ProcessPodcast implements Job { + final Podcast podcast; + + @override + Future handle() async { + await podcast.process(); + } +} + +// Push job to queue +await queue.push(ProcessPodcast(podcast)); + +// Push delayed job +await queue.later( + Duration(minutes: 10), + ProcessPodcast(podcast) +); +``` + +### 2. Job Batching +```dart +// Create batch +var batch = await queue.batch([ + ProcessPodcast(podcast1), + ProcessPodcast(podcast2), + ProcessPodcast(podcast3) +]) +.allowFailures() +.dispatch(); + +// Add more jobs +await batch.add([ + ProcessPodcast(podcast4), + ProcessPodcast(podcast5) +]); + +// Check progress +print('Pending: ${await batch.pendingJobs}'); +print('Failed: ${await batch.failedJobs}'); +``` + +### 3. Queue Worker +```dart +// Start worker +var worker = QueueWorker(connection) + ..onJob((job) async { + print('Processing job ${job.id}'); + }) + ..onException((job, exception) async { + print('Job ${job.id} failed: $exception'); + }); + +await worker.daemon([ + 'default', + 'emails', + 'podcasts' +]); +``` + +## Testing + +```dart +void main() { + group('Queue Manager', () { + test('pushes jobs to queue', () async { + var queue = QueueManager(config); + var job = ProcessPodcast(podcast); + + var id = await queue.push(job); + + expect(id, isNotEmpty); + verify(() => connection.push(job, null)).called(1); + }); + + test('handles delayed jobs', () async { + var queue = QueueManager(config); + var job = ProcessPodcast(podcast); + var delay = Duration(minutes: 5); + + await queue.later(delay, job); + + verify(() => connection.later(delay, job, null)).called(1); + }); + }); + + group('Job Batching', () { + test('processes job batches', () async { + var batch = await queue.batch([ + ProcessPodcast(podcast1), + ProcessPodcast(podcast2) + ]).dispatch(); + + expect(batch.totalJobs, equals(2)); + expect(await batch.pendingJobs, equals(2)); + expect(await batch.failedJobs, equals(0)); + }); + }); +} +``` + +## Next Steps + +1. Implement core queue features +2. Add queue connections +3. Add job batching +4. Add queue worker +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing queue features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Events Package Specification](events_package_specification.md) +6. Review [Bus Package Specification](bus_package_specification.md) + +### 2. Implementation Process +For each queue feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support event integration (see [Events Package Specification](events_package_specification.md)) +5. Support bus integration (see [Bus Package Specification](bus_package_specification.md)) + +### 4. Integration Considerations +When implementing queue features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Queue system must: +1. Handle high job throughput +2. Process batches efficiently +3. Support concurrent workers +4. Scale horizontally +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Queue tests must: +1. Cover all queue operations +2. Test job processing +3. Verify batching +4. Check worker behavior +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Queue documentation must: +1. Explain queue patterns +2. Show job examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/route_gap_analysis.md b/docs/route_gap_analysis.md new file mode 100644 index 0000000..090048e --- /dev/null +++ b/docs/route_gap_analysis.md @@ -0,0 +1,295 @@ +# Route Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Route package's actual implementation and Laravel's routing functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Route Package Specification](route_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Pipeline Package Specification](pipeline_package_specification.md) for middleware pipeline +> - See [Container Package Specification](container_package_specification.md) for dependency injection + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Route Model Binding +class RouteModelBinding { + // Need to implement: + void bind(String key, Type type); + void bindWhere(String key, Type type, Function where); + void bindCallback(String key, Function callback); + void scopeBindings(); +} + +// 2. Route Caching +class RouteCache { + // Need to implement: + Future cache(); + Future clear(); + bool isEnabled(); + Future reload(); + Future compile(); +} + +// 3. Route Fallbacks +class RouteFallback { + // Need to implement: + void fallback(dynamic action); + void missing(Function callback); + void methodNotAllowed(Function callback); + void notFound(Function callback); +} +``` + +### 2. Missing Route Features +```dart +// Need to implement: + +// 1. Route Constraints +class RouteConstraints { + // Need to implement: + void pattern(String name, String pattern); + void patterns(Map patterns); + bool matches(String name, String value); + void whereNumber(List parameters); + void whereAlpha(List parameters); + void whereAlphaNumeric(List parameters); + void whereUuid(List parameters); +} + +// 2. Route Substitutions +class RouteSubstitution { + // Need to implement: + void substitute(String key, dynamic value); + void substituteBindings(Map bindings); + void substituteImplicit(Map bindings); + String resolveBinding(String key); +} + +// 3. Route Rate Limiting +class RouteRateLimiting { + // Need to implement: + void throttle(String name, int maxAttempts, Duration decay); + void rateLimit(String name, int maxAttempts, Duration decay); + void forUser(String name, int maxAttempts, Duration decay); + void exempt(List routes); +} +``` + +### 3. Missing Group Features +```dart +// Need to implement: + +// 1. Group Attributes +class RouteGroupAttributes { + // Need to implement: + void controller(Type controller); + void namespace(String namespace); + void name(String name); + void domain(String domain); + void where(Map patterns); +} + +// 2. Group Middleware +class RouteGroupMiddleware { + // Need to implement: + void aliasMiddleware(String name, Type middleware); + void middlewarePriority(List middleware); + void pushMiddlewareToGroup(String group, String middleware); + List getMiddlewareGroups(); +} + +// 3. Group Resources +class RouteGroupResources { + // Need to implement: + void resources(Map resources); + void apiResources(Map resources); + void singleton(String name, Type controller); + void apiSingleton(String name, Type controller); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Binds route model. +/// +/// Example: +/// ```dart +/// router.bind('user', User, (value) async { +/// return await User.find(value); +/// }); +/// ``` +void bind(String key, Type type, [Function? callback]); + +/// Defines route pattern. +/// +/// Example: +/// ```dart +/// router.pattern('id', '[0-9]+'); +/// router.get('users/{id}', UsersController) +/// .where('id', '[0-9]+'); +/// ``` +void pattern(String name, String pattern); +``` + +### 2. Missing Integration Examples +```dart +// Need examples for: + +// 1. Route Model Binding +router.bind('user', User); + +router.get('users/{user}', (User user) { + return user; // Auto-resolved from ID +}); + +// 2. Route Rate Limiting +router.middleware(['throttle:60,1']) + .group(() { + router.get('api/users', UsersController); + }); + +// 3. Route Resources +router.resources({ + 'photos': PhotoController, + 'posts': PostController +}); +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Route Model Binding', () { + test('resolves bound models', () async { + router.bind('user', User); + + var response = await router.dispatch( + Request('GET', '/users/1') + ); + + expect(response.data, isA()); + expect(response.data.id, equals('1')); + }); + }); + + group('Route Caching', () { + test('caches compiled routes', () async { + await router.cache(); + + var route = router.match( + Request('GET', '/users/1') + ); + + expect(route, isNotNull); + expect(route!.compiled, isTrue); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Route model binding (Laravel compatibility) + - Route caching (Laravel compatibility) + - Route constraints + +2. **Medium Priority** + - Route substitutions + - Route rate limiting + - Group resources + +3. **Low Priority** + - Route fallbacks + - Additional constraints + - Performance optimizations + +## Next Steps + +1. **Implementation Tasks** + - Add route model binding + - Add route caching + - Add route constraints + - Add group resources + +2. **Documentation Tasks** + - Document model binding + - Document caching + - Document constraints + - Add integration examples + +3. **Testing Tasks** + - Add binding tests + - Add caching tests + - Add constraint tests + - Add resource tests + +## Development Guidelines + +### 1. Getting Started +Before implementing routing features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Route Package Specification](route_package_specification.md) +6. Review [Pipeline Package Specification](pipeline_package_specification.md) +7. Review [Container Package Specification](container_package_specification.md) + +### 2. Implementation Process +For each routing feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Route Package Specification](route_package_specification.md) + +### 4. Integration Considerations +When implementing routing features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Routing system must: +1. Match routes efficiently +2. Handle complex patterns +3. Support caching +4. Scale with route count +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Route tests must: +1. Cover all route types +2. Test pattern matching +3. Verify middleware +4. Check parameter binding +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Route documentation must: +1. Explain routing patterns +2. Show group examples +3. Cover parameter binding +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/route_package_specification.md b/docs/route_package_specification.md new file mode 100644 index 0000000..24db217 --- /dev/null +++ b/docs/route_package_specification.md @@ -0,0 +1,550 @@ +# Route Package Specification + +## Overview + +The Route package provides a robust routing system that matches Laravel's routing functionality. It supports route registration, middleware, parameter binding, and route groups while integrating with our Pipeline and Container packages. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Pipeline Package Specification](pipeline_package_specification.md) for middleware pipeline +> - See [Container Package Specification](container_package_specification.md) for dependency injection + +## Core Features + +### 1. Router + +```dart +/// Core router implementation +class Router implements RouterContract { + /// Container instance + final Container _container; + + /// Route collection + final RouteCollection _routes; + + /// Current route + Route? _current; + + /// Global middleware + final List _middleware = []; + + Router(this._container) + : _routes = RouteCollection(); + + /// Gets current route + Route? get current => _current; + + /// Gets global middleware + List get middleware => List.from(_middleware); + + /// Adds global middleware + void pushMiddleware(dynamic middleware) { + _middleware.add(middleware); + } + + /// Registers GET route + Route get(String uri, dynamic action) { + return addRoute(['GET', 'HEAD'], uri, action); + } + + /// Registers POST route + Route post(String uri, dynamic action) { + return addRoute(['POST'], uri, action); + } + + /// Registers PUT route + Route put(String uri, dynamic action) { + return addRoute(['PUT'], uri, action); + } + + /// Registers DELETE route + Route delete(String uri, dynamic action) { + return addRoute(['DELETE'], uri, action); + } + + /// Registers PATCH route + Route patch(String uri, dynamic action) { + return addRoute(['PATCH'], uri, action); + } + + /// Registers OPTIONS route + Route options(String uri, dynamic action) { + return addRoute(['OPTIONS'], uri, action); + } + + /// Adds route to collection + Route addRoute(List methods, String uri, dynamic action) { + var route = Route(methods, uri, action); + _routes.add(route); + return route; + } + + /// Creates route group + void group(Map attributes, Function callback) { + var group = RouteGroup(attributes); + + _routes.pushGroup(group); + callback(); + _routes.popGroup(); + } + + /// Matches request to route + Route? match(Request request) { + _current = _routes.match(request); + return _current; + } + + /// Dispatches request to route + Future dispatch(Request request) async { + var route = match(request); + if (route == null) { + throw RouteNotFoundException(); + } + + return await _runRoute(route, request); + } + + /// Runs route through middleware + Future _runRoute(Route route, Request request) async { + var pipeline = _container.make(); + + return await pipeline + .send(request) + .through([ + ..._middleware, + ...route.gatherMiddleware() + ]) + .then((request) => route.run(request)); + } +} +``` + +### 2. Route Collection + +```dart +/// Route collection +class RouteCollection { + /// Routes by method + final Map> _routes = {}; + + /// Route groups + final List _groups = []; + + /// Adds route to collection + void add(Route route) { + for (var method in route.methods) { + _routes.putIfAbsent(method, () => []).add(route); + } + + if (_groups.isNotEmpty) { + route.group = _groups.last; + } + } + + /// Pushes route group + void pushGroup(RouteGroup group) { + if (_groups.isNotEmpty) { + group.parent = _groups.last; + } + _groups.add(group); + } + + /// Pops route group + void popGroup() { + _groups.removeLast(); + } + + /// Matches request to route + Route? match(Request request) { + var routes = _routes[request.method] ?? []; + + for (var route in routes) { + if (route.matches(request)) { + return route; + } + } + + return null; + } +} +``` + +### 3. Route + +```dart +/// Route definition +class Route { + /// HTTP methods + final List methods; + + /// URI pattern + final String uri; + + /// Route action + final dynamic action; + + /// Route group + RouteGroup? group; + + /// Route middleware + final List _middleware = []; + + /// Route parameters + final Map _parameters = {}; + + Route(this.methods, this.uri, this.action); + + /// Adds middleware + Route middleware(List middleware) { + _middleware.addAll(middleware); + return this; + } + + /// Gets route name + String? get name => _parameters['as']; + + /// Sets route name + Route name(String name) { + _parameters['as'] = name; + return this; + } + + /// Gets route domain + String? get domain => _parameters['domain']; + + /// Sets route domain + Route domain(String domain) { + _parameters['domain'] = domain; + return this; + } + + /// Gets route prefix + String get prefix { + var prefix = ''; + var group = this.group; + + while (group != null) { + if (group.prefix != null) { + prefix = '${group.prefix}/$prefix'; + } + group = group.parent; + } + + return prefix.isEmpty ? '' : prefix; + } + + /// Gets full URI + String get fullUri => '${prefix.isEmpty ? "" : "$prefix/"}$uri'; + + /// Gathers middleware + List gatherMiddleware() { + var middleware = [..._middleware]; + var group = this.group; + + while (group != null) { + middleware.addAll(group.middleware); + group = group.parent; + } + + return middleware; + } + + /// Matches request + bool matches(Request request) { + return _matchesMethod(request.method) && + _matchesUri(request.uri) && + _matchesDomain(request.host); + } + + /// Matches HTTP method + bool _matchesMethod(String method) { + return methods.contains(method); + } + + /// Matches URI pattern + bool _matchesUri(Uri uri) { + var pattern = RegExp(_compilePattern()); + return pattern.hasMatch(uri.path); + } + + /// Matches domain pattern + bool _matchesDomain(String? host) { + if (domain == null) return true; + if (host == null) return false; + + var pattern = RegExp(_compileDomainPattern()); + return pattern.hasMatch(host); + } + + /// Compiles URI pattern + String _compilePattern() { + return fullUri + .replaceAll('/', '\\/') + .replaceAllMapped( + RegExp(r'{([^}]+)}'), + (match) => '(?<${match[1]}>[^/]+)' + ); + } + + /// Compiles domain pattern + String _compileDomainPattern() { + return domain! + .replaceAll('.', '\\.') + .replaceAllMapped( + RegExp(r'{([^}]+)}'), + (match) => '(?<${match[1]}>[^.]+)' + ); + } + + /// Runs route action + Future run(Request request) async { + var action = _resolveAction(); + var parameters = _resolveParameters(request); + + if (action is Function) { + return await Function.apply(action, parameters); + } + + if (action is Controller) { + return await action.callAction( + action.runtimeType.toString(), + parameters + ); + } + + throw RouteActionNotFoundException(); + } + + /// Resolves route action + dynamic _resolveAction() { + if (action is String) { + var parts = action.split('@'); + var controller = _container.make(parts[0]); + controller.method = parts[1]; + return controller; + } + + return action; + } + + /// Resolves route parameters + List _resolveParameters(Request request) { + var pattern = RegExp(_compilePattern()); + var match = pattern.firstMatch(request.uri.path); + + if (match == null) return []; + + return match.groupNames.map((name) { + return _resolveParameter(name, match.namedGroup(name)!); + }).toList(); + } + + /// Resolves route parameter + dynamic _resolveParameter(String name, String value) { + if (_parameters.containsKey(name)) { + return _parameters[name](value); + } + + return value; + } +} +``` + +### 4. Route Groups + +```dart +/// Route group +class RouteGroup { + /// Group attributes + final Map attributes; + + /// Parent group + RouteGroup? parent; + + RouteGroup(this.attributes); + + /// Gets group prefix + String? get prefix => attributes['prefix']; + + /// Gets group middleware + List get middleware => attributes['middleware'] ?? []; + + /// Gets group domain + String? get domain => attributes['domain']; + + /// Gets group name prefix + String? get namePrefix => attributes['as']; + + /// Gets merged attributes + Map get mergedAttributes { + var merged = Map.from(attributes); + var parent = this.parent; + + while (parent != null) { + for (var entry in parent.attributes.entries) { + if (!merged.containsKey(entry.key)) { + merged[entry.key] = entry.value; + } + } + parent = parent.parent; + } + + return merged; + } +} +``` + +## Integration Examples + +### 1. Basic Routing +```dart +// Register routes +router.get('/', HomeController); +router.post('/users', UsersController); +router.get('/users/{id}', (String id) { + return User.find(id); +}); + +// Match and dispatch +var route = router.match(request); +var response = await router.dispatch(request); +``` + +### 2. Route Groups +```dart +router.group({ + 'prefix': 'api', + 'middleware': ['auth'], + 'namespace': 'Api' +}, () { + router.get('users', UsersController); + router.get('posts', PostsController); + + router.group({ + 'prefix': 'admin', + 'middleware': ['admin'] + }, () { + router.get('stats', StatsController); + }); +}); +``` + +### 3. Route Parameters +```dart +// Required parameters +router.get('users/{id}', (String id) { + return User.find(id); +}); + +// Optional parameters +router.get('posts/{id?}', (String? id) { + return id != null ? Post.find(id) : Post.all(); +}); + +// Regular expression constraints +router.get('users/{id}', UsersController) + .where('id', '[0-9]+'); +``` + +## Testing + +```dart +void main() { + group('Router', () { + test('matches routes', () { + var router = Router(container); + router.get('/users/{id}', UsersController); + + var request = Request('GET', '/users/1'); + var route = router.match(request); + + expect(route, isNotNull); + expect(route!.action, equals(UsersController)); + }); + + test('handles route groups', () { + var router = Router(container); + + router.group({ + 'prefix': 'api', + 'middleware': ['auth'] + }, () { + router.get('users', UsersController); + }); + + var route = router.match(Request('GET', '/api/users')); + expect(route, isNotNull); + expect(route!.gatherMiddleware(), contains('auth')); + }); + }); +} +``` + +## Next Steps + +1. Implement core routing +2. Add route groups +3. Add route parameters +4. Add middleware support +5. Write tests +6. Add benchmarks + +## Development Guidelines + +### 1. Getting Started +Before implementing routing features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Pipeline Package Specification](pipeline_package_specification.md) +6. Review [Container Package Specification](container_package_specification.md) + +### 2. Implementation Process +For each routing feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support middleware (see [Pipeline Package Specification](pipeline_package_specification.md)) +5. Support dependency injection (see [Container Package Specification](container_package_specification.md)) + +### 4. Integration Considerations +When implementing routing features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Routing system must: +1. Match routes efficiently +2. Handle complex patterns +3. Support caching +4. Scale with route count +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Route tests must: +1. Cover all route types +2. Test pattern matching +3. Verify middleware +4. Check parameter binding +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Route documentation must: +1. Explain routing patterns +2. Show group examples +3. Cover parameter binding +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/support_package_specification.md b/docs/support_package_specification.md new file mode 100644 index 0000000..7e47200 --- /dev/null +++ b/docs/support_package_specification.md @@ -0,0 +1,436 @@ +# Support Package Specification + +## Overview + +The Support package provides fundamental utilities, helper functions, and common abstractions used throughout the framework. It aims to match Laravel's Support package functionality while leveraging Dart's strengths. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Contracts Package Specification](contracts_package_specification.md) for support contracts + +## Core Features + +### 1. Collections + +```dart +/// Provides Laravel-like collection operations +class Collection { + final List _items; + + Collection(this._items); + + /// Creates a collection from an iterable + factory Collection.from(Iterable items) { + return Collection(items.toList()); + } + + /// Maps items while maintaining collection type + Collection map(R Function(T) callback) { + return Collection(_items.map(callback).toList()); + } + + /// Filters items + Collection where(bool Function(T) test) { + return Collection(_items.where(test).toList()); + } + + /// Reduces collection to single value + R reduce(R Function(R, T) callback, R initial) { + return _items.fold(initial, callback); + } + + /// Groups items by key + Map> groupBy(K Function(T) keySelector) { + return _items.fold({}, (map, item) { + var key = keySelector(item); + map.putIfAbsent(key, () => []).add(item); + return map; + }); + } +} +``` + +### 2. String Manipulation + +```dart +/// Provides Laravel-like string manipulation +extension StringHelpers on String { + /// Converts string to camelCase + String camelCase() { + var words = split(RegExp(r'[\s_-]+')); + return words.first + + words.skip(1) + .map((w) => w[0].toUpperCase() + w.substring(1)) + .join(); + } + + /// Converts string to snake_case + String snakeCase() { + return replaceAllMapped( + RegExp(r'[A-Z]'), + (m) => '_${m[0]!.toLowerCase()}' + ).replaceAll(RegExp(r'^_'), ''); + } + + /// Converts string to kebab-case + String kebabCase() { + return snakeCase().replaceAll('_', '-'); + } + + /// Converts string to StudlyCase + String studlyCase() { + return split(RegExp(r'[\s_-]+')) + .map((w) => w[0].toUpperCase() + w.substring(1)) + .join(); + } +} +``` + +### 3. Array/List Helpers + +```dart +/// Provides Laravel-like array manipulation +extension ArrayHelpers on List { + /// Gets first item matching predicate + T? firstWhere(bool Function(T) test, {T? orElse()}) { + try { + return super.firstWhere(test); + } catch (e) { + return orElse?.call(); + } + } + + /// Plucks single field from list of maps + List pluck(String key) { + return map((item) => + (item as Map)[key] as V + ).toList(); + } + + /// Groups items by key + Map> groupBy(K Function(T) keySelector) { + return fold({}, (map, item) { + var key = keySelector(item); + map.putIfAbsent(key, () => []).add(item); + return map; + }); + } +} +``` + +### 4. Service Provider Support + +```dart +/// Base class for service providers +abstract class ServiceProvider { + /// The container instance + late final Container container; + + /// Register bindings with the container + void register(); + + /// Bootstrap any application services + void boot() {} + + /// Determines if provider is deferred + bool get isDeferred => false; + + /// Gets services provided + List get provides => []; +} + +/// Marks a provider as deferred +abstract class DeferredServiceProvider extends ServiceProvider { + @override + bool get isDeferred => true; + + /// Gets events that trigger loading + List get when => []; +} +``` + +### 5. Fluent Interface + +```dart +/// Provides fluent interface building +class Fluent { + final Map _attributes; + + Fluent([Map? attributes]) + : _attributes = attributes ?? {}; + + /// Gets attribute value + T? get(String key) => _attributes[key] as T?; + + /// Sets attribute value + Fluent set(String key, dynamic value) { + _attributes[key] = value; + return this; + } + + /// Gets all attributes + Map toMap() => Map.from(_attributes); +} +``` + +### 6. Optional Type + +```dart +/// Provides Laravel-like Optional type +class Optional { + final T? _value; + + const Optional(this._value); + + /// Creates Optional from nullable value + factory Optional.of(T? value) => Optional(value); + + /// Gets value or default + T get(T defaultValue) => _value ?? defaultValue; + + /// Maps value if present + Optional map(R Function(T) mapper) { + return Optional(_value == null ? null : mapper(_value!)); + } + + /// Returns true if value is present + bool get isPresent => _value != null; +} +``` + +### 7. High Order Message Proxies + +```dart +/// Provides Laravel-like high order messaging +class HigherOrderProxy { + final T _target; + + HigherOrderProxy(this._target); + + /// Invokes method on target + R call(String method, [List? args]) { + return Function.apply( + _target.runtimeType.getMethod(method), + args ?? [] + ) as R; + } +} +``` + +## Integration with Container + +```dart +/// Register support services +class SupportServiceProvider extends ServiceProvider { + @override + void register() { + // Register collection factory + container.bind((c) => CollectionFactory()); + + // Register string helpers + container.bind((c) => StringHelpers()); + + // Register array helpers + container.bind((c) => ArrayHelpers()); + } +} +``` + +## Usage Examples + +### Collections +```dart +// Create collection +var collection = Collection([1, 2, 3, 4, 5]); + +// Chain operations +var result = collection + .where((n) => n.isEven) + .map((n) => n * 2) + .reduce((sum, n) => sum + n, 0); + +// Group items +var users = Collection([ + User('John', 'Admin'), + User('Jane', 'User'), + User('Bob', 'Admin') +]); + +var byRole = users.groupBy((u) => u.role); +``` + +### String Helpers +```dart +// Convert cases +'user_name'.camelCase(); // userName +'userName'.snakeCase(); // user_name +'user name'.studlyCase(); // UserName +'UserName'.kebabCase(); // user-name + +// Other operations +'hello'.padLeft(10); // ' hello' +'HELLO'.toLowerCase(); // 'hello' +' text '.trim(); // 'text' +``` + +### Service Providers +```dart +class UserServiceProvider extends ServiceProvider { + @override + void register() { + container.bind((c) => UserRepositoryImpl()); + } + + @override + void boot() { + var repo = container.make(); + repo.initialize(); + } +} + +class CacheServiceProvider extends DeferredServiceProvider { + @override + List get when => ['cache.needed']; + + @override + List get provides => [CacheManager]; + + @override + void register() { + container.bind((c) => CacheManagerImpl()); + } +} +``` + +## Testing + +```dart +void main() { + group('Collection Tests', () { + test('should map values', () { + var collection = Collection([1, 2, 3]); + var result = collection.map((n) => n * 2); + expect(result.toList(), equals([2, 4, 6])); + }); + + test('should filter values', () { + var collection = Collection([1, 2, 3, 4]); + var result = collection.where((n) => n.isEven); + expect(result.toList(), equals([2, 4])); + }); + }); + + group('String Helper Tests', () { + test('should convert to camelCase', () { + expect('user_name'.camelCase(), equals('userName')); + expect('first name'.camelCase(), equals('firstName')); + }); + + test('should convert to snakeCase', () { + expect('userName'.snakeCase(), equals('user_name')); + expect('FirstName'.snakeCase(), equals('first_name')); + }); + }); +} +``` + +## Performance Considerations + +1. **Collection Operations** +```dart +// Use lazy evaluation when possible +collection + .where((n) => n.isEven) // Lazy + .map((n) => n * 2) // Lazy + .toList(); // Eager +``` + +2. **String Manipulations** +```dart +// Cache regex patterns +final _camelCasePattern = RegExp(r'[\s_-]+'); +final _snakeCasePattern = RegExp(r'[A-Z]'); + +// Use StringBuffer for concatenation +final buffer = StringBuffer(); +for (var word in words) { + buffer.write(word); +} +``` + +3. **Service Provider Loading** +```dart +// Defer provider loading when possible +class HeavyServiceProvider extends DeferredServiceProvider { + @override + List get when => ['heavy.needed']; +} +``` + +## Next Steps + +1. Implement core features +2. Add comprehensive tests +3. Create integration examples +4. Add performance benchmarks + +Would you like me to continue with documentation for the Pipeline or Contracts package? + +## Development Guidelines + +### 1. Getting Started +Before implementing support features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Understand [Contracts Package Specification](contracts_package_specification.md) + +### 2. Implementation Process +For each support feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Implement required contracts (see [Contracts Package Specification](contracts_package_specification.md)) + +### 4. Integration Considerations +When implementing support features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) +5. Implement all contracts from [Contracts Package Specification](contracts_package_specification.md) + +### 5. Performance Guidelines +Support utilities must: +1. Handle large collections efficiently +2. Optimize string operations +3. Minimize memory allocations +4. Support async operations where appropriate +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Support tests must: +1. Cover all utility functions +2. Test edge cases +3. Verify error handling +4. Check performance characteristics +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Support documentation must: +1. Explain utility patterns +2. Show usage examples +3. Cover error handling +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 178c63c..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,10 +0,0 @@ -# Performance Testing - -The performance test can be run with the following tools. - -## WRT - - ```bash - wrk -t12 -c400 -d30s http://localhost:8080/query?queries=20 - ``` -This runs a benchmark for 30 seconds, using 12 threads, and keeping 400 HTTP connections open. diff --git a/docs/testing_gap_analysis.md b/docs/testing_gap_analysis.md new file mode 100644 index 0000000..5814aca --- /dev/null +++ b/docs/testing_gap_analysis.md @@ -0,0 +1,312 @@ +# Testing Package Gap Analysis + +## Overview + +This document analyzes the gaps between our Testing package's actual implementation and Laravel's testing functionality, identifying areas that need implementation or documentation updates. + +> **Related Documentation** +> - See [Testing Package Specification](testing_package_specification.md) for current implementation +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for overall status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Container Package Specification](container_package_specification.md) for dependency injection +> - See [Events Package Specification](events_package_specification.md) for event testing + +## Implementation Gaps + +### 1. Missing Laravel Features +```dart +// Documented but not implemented: + +// 1. Browser Testing +class BrowserTest { + // Need to implement: + Future browse(Function(Browser) callback); + Future visit(String page); + Future click(String text); + Future type(String field, String value); + Future press(String button); + Future assertSee(String text); + Future assertPathIs(String path); +} + +// 2. Parallel Testing +class ParallelTesting { + // Need to implement: + void setToken(String token); + void setProcesses(int count); + Future runInParallel(); + Future withoutOverlapping(String key); + Future isolateDatabase(); +} + +// 3. Time Testing +class TimeTesting { + // Need to implement: + void travel(Duration duration); + void freeze(DateTime time); + void resume(); + void setTestNow(DateTime time); + DateTime now(); +} +``` + +### 2. Missing Test Features +```dart +// Need to implement: + +// 1. Test Data Factories +class TestDataFactory { + // Need to implement: + T define(Map attributes); + T make([Map? attributes]); + Future create([Map? attributes]); + List makeMany(int count, [Map? attributes]); + Future> createMany(int count, [Map? attributes]); +} + +// 2. Test Doubles +class TestDoubles { + // Need to implement: + dynamic spy(dynamic target); + dynamic mock(Type type); + dynamic fake(Type type); + dynamic partial(Type type); + void verifyNever(Function invocation); + void verifyOnce(Function invocation); +} + +// 3. Test Database +class TestDatabase { + // Need to implement: + Future beginTransaction(); + Future rollback(); + Future refresh(); + Future seed(String class); + Future truncate(List tables); +} +``` + +### 3. Missing Assertion Features +```dart +// Need to implement: + +// 1. Collection Assertions +class CollectionAssertions { + // Need to implement: + void assertCount(int count); + void assertEmpty(); + void assertContains(dynamic item); + void assertDoesntContain(dynamic item); + void assertHasKey(String key); + void assertHasValue(dynamic value); +} + +// 2. Response Assertions +class ResponseAssertions { + // Need to implement: + void assertViewIs(String name); + void assertViewHas(String key, [dynamic value]); + void assertViewMissing(String key); + void assertSessionHas(String key, [dynamic value]); + void assertSessionMissing(String key); + void assertCookie(String name, [String? value]); +} + +// 3. Exception Assertions +class ExceptionAssertions { + // Need to implement: + void assertThrows(Function callback); + void assertDoesntThrow(Function callback); + void assertThrowsMessage(Type type, String message); + void assertThrowsIf(bool condition, Function callback); +} +``` + +## Documentation Gaps + +### 1. Missing API Documentation +```dart +// Need to document: + +/// Runs browser test. +/// +/// Example: +/// ```dart +/// await browse((browser) async { +/// await browser.visit('/login'); +/// await browser.type('email', 'user@example.com'); +/// await browser.press('Login'); +/// await browser.assertPathIs('/dashboard'); +/// }); +/// ``` +Future browse(Function(Browser) callback); + +/// Creates test data factory. +/// +/// Example: +/// ```dart +/// class UserFactory extends Factory { +/// @override +/// User define() { +/// return User() +/// ..name = faker.person.name() +/// ..email = faker.internet.email(); +/// } +/// } +/// ``` +abstract class Factory; +``` + +### 2. Missing Integration Examples +```dart +// Need examples for: + +// 1. Parallel Testing +await test.parallel((runner) { + runner.setProcesses(4); + runner.isolateDatabase(); + await runner.run(); +}); + +// 2. Time Testing +await test.freeze(DateTime(2024, 1, 1), () async { + await processScheduledJobs(); + await test.travel(Duration(days: 1)); + await verifyJobsCompleted(); +}); + +// 3. Test Doubles +var mock = test.mock(PaymentGateway); +when(mock.charge(any)).thenReturn(true); + +await processPayment(mock); +verify(mock.charge(any)).called(1); +``` + +### 3. Missing Test Coverage +```dart +// Need tests for: + +void main() { + group('Browser Testing', () { + test('interacts with browser', () async { + await browse((browser) async { + await browser.visit('/login'); + await browser.type('email', 'test@example.com'); + await browser.type('password', 'password'); + await browser.press('Login'); + + await browser.assertPathIs('/dashboard'); + await browser.assertSee('Welcome'); + }); + }); + }); + + group('Test Factories', () { + test('creates test data', () async { + var users = await UserFactory() + .count(3) + .create(); + + expect(users, hasLength(3)); + expect(users.first.email, contains('@')); + }); + }); +} +``` + +## Implementation Priority + +1. **High Priority** + - Browser testing (Laravel compatibility) + - Parallel testing (Laravel compatibility) + - Test data factories + +2. **Medium Priority** + - Test doubles + - Time testing + - Test database features + +3. **Low Priority** + - Additional assertions + - Additional test helpers + - Performance optimizations + +## Next Steps + +1. **Implementation Tasks** + - Add browser testing + - Add parallel testing + - Add test factories + - Add test doubles + +2. **Documentation Tasks** + - Document browser testing + - Document parallel testing + - Document factories + - Add integration examples + +3. **Testing Tasks** + - Add browser tests + - Add parallel tests + - Add factory tests + - Add double tests + +## Development Guidelines + +### 1. Getting Started +Before implementing testing features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Testing Package Specification](testing_package_specification.md) +6. Review [Container Package Specification](container_package_specification.md) +7. Review [Events Package Specification](events_package_specification.md) + +### 2. Implementation Process +For each testing feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Match specifications in [Testing Package Specification](testing_package_specification.md) + +### 4. Integration Considerations +When implementing testing features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Testing system must: +1. Execute tests efficiently +2. Support parallel testing +3. Handle large test suites +4. Manage test isolation +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Testing package tests must: +1. Cover all testing features +2. Test browser interactions +3. Verify parallel execution +4. Check test factories +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Testing documentation must: +1. Explain testing patterns +2. Show browser examples +3. Cover parallel testing +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/docs/testing_guide.md b/docs/testing_guide.md new file mode 100644 index 0000000..d0129e7 --- /dev/null +++ b/docs/testing_guide.md @@ -0,0 +1,343 @@ +# Testing Guide + +## Overview + +This guide outlines our testing approach, which follows Laravel's testing patterns while leveraging Dart's testing capabilities. It covers unit testing, integration testing, performance testing, and Laravel-style testing approaches. + +## Test Types + +### 1. Unit Tests +```dart +void main() { + group('Service Tests', () { + late Container container; + late UserService service; + + setUp(() { + container = Container(reflector); + container.bind((c) => MockDatabase()); + service = container.make(); + }); + + test('creates user', () async { + var user = await service.create({ + 'name': 'John Doe', + 'email': 'john@example.com' + }); + + expect(user.name, equals('John Doe')); + expect(user.email, equals('john@example.com')); + }); + + test('validates user data', () { + expect( + () => service.create({'name': 'John Doe'}), + throwsA(isA()) + ); + }); + }); +} +``` + +### 2. Integration Tests +```dart +void main() { + group('API Integration', () { + late Application app; + + setUp(() async { + app = await createApplication(); + await app.initialize(); + }); + + tearDown(() async { + await app.shutdown(); + }); + + test('creates user through API', () async { + var response = await app.post('/users', body: { + 'name': 'John Doe', + 'email': 'john@example.com' + }); + + expect(response.statusCode, equals(201)); + expect(response.json['name'], equals('John Doe')); + }); + + test('handles validation errors', () async { + var response = await app.post('/users', body: { + 'name': 'John Doe' + }); + + expect(response.statusCode, equals(422)); + expect(response.json['errors'], contains('email')); + }); + }); +} +``` + +### 3. Performance Tests +```dart +void main() { + group('Performance Tests', () { + late Application app; + + setUp(() async { + app = await createApplication(); + await app.initialize(); + }); + + test('handles concurrent requests', () async { + var stopwatch = Stopwatch()..start(); + + // Create 100 concurrent requests + var futures = List.generate(100, (i) => + app.get('/users') + ); + + var responses = await Future.wait(futures); + stopwatch.stop(); + + // Verify responses + expect(responses, everyElement( + predicate((r) => r.statusCode == 200) + )); + + // Check performance + expect( + stopwatch.elapsedMilliseconds / responses.length, + lessThan(100) // Less than 100ms per request + ); + }); + + test('handles database operations efficiently', () async { + var stopwatch = Stopwatch()..start(); + + // Create 1000 records + for (var i = 0; i < 1000; i++) { + await app.post('/users', body: { + 'name': 'User $i', + 'email': 'user$i@example.com' + }); + } + + stopwatch.stop(); + + // Check performance + expect( + stopwatch.elapsedMilliseconds / 1000, + lessThan(50) // Less than 50ms per operation + ); + }); + }); +} +``` + +### 4. Laravel-Style Feature Tests +```dart +void main() { + group('Feature Tests', () { + late TestCase test; + + setUp(() { + test = await TestCase.make(); + }); + + test('user can register', () async { + await test + .post('/register', { + 'name': 'John Doe', + 'email': 'john@example.com', + 'password': 'password', + 'password_confirmation': 'password' + }) + .assertStatus(302) + .assertRedirect('/home'); + + test.assertDatabaseHas('users', { + 'email': 'john@example.com' + }); + }); + + test('user can login', () async { + // Create user + await test.createUser({ + 'email': 'john@example.com', + 'password': 'password' + }); + + await test + .post('/login', { + 'email': 'john@example.com', + 'password': 'password' + }) + .assertAuthenticated(); + }); + }); +} +``` + +## Performance Testing Tools + +### 1. WRK Benchmarking +```bash +# Basic load test +wrk -t12 -c400 -d30s http://localhost:8080/api/endpoint + +# Test with custom script +wrk -t12 -c400 -d30s -s script.lua http://localhost:8080/api/endpoint +``` + +### 2. Custom Load Testing +```dart +void main() { + test('load test', () async { + var client = HttpClient(); + var stopwatch = Stopwatch()..start(); + + // Configure test + var duration = Duration(minutes: 1); + var concurrency = 100; + var results = []; + + // Run test + while (stopwatch.elapsed < duration) { + var requests = List.generate(concurrency, (i) async { + var requestWatch = Stopwatch()..start(); + await client.get('localhost', 8080, '/api/endpoint'); + requestWatch.stop(); + results.add(requestWatch.elapsed); + }); + + await Future.wait(requests); + } + + // Analyze results + var average = results.reduce((a, b) => a + b) ~/ results.length; + var sorted = List.of(results)..sort(); + var p95 = sorted[(sorted.length * 0.95).floor()]; + var p99 = sorted[(sorted.length * 0.99).floor()]; + + print('Results:'); + print('Average: ${average.inMilliseconds}ms'); + print('P95: ${p95.inMilliseconds}ms'); + print('P99: ${p99.inMilliseconds}ms'); + }); +} +``` + +## Best Practices + +### 1. Test Organization +```dart +// Group related tests +group('UserService', () { + group('creation', () { + test('creates valid user', () {}); + test('validates input', () {}); + test('handles duplicates', () {}); + }); + + group('authentication', () { + test('authenticates valid credentials', () {}); + test('rejects invalid credentials', () {}); + }); +}); +``` + +### 2. Test Data Management +```dart +class TestCase { + // Create test data + Future createUser([Map? attributes]) async { + return factory.create(User, attributes); + } + + // Clean up after tests + Future cleanup() async { + await database.truncate(['users', 'posts', 'comments']); + } +} +``` + +### 3. Assertions +```dart +// Use descriptive assertions +expect(user.name, equals('John Doe'), + reason: 'User name should match input'); + +expect(response.statusCode, + isIn([200, 201]), + reason: 'Response should indicate success' +); + +expect( + () => service.validateEmail('invalid'), + throwsA(isA()), + reason: 'Should reject invalid email' +); +``` + +## Performance Benchmarks + +### 1. Response Time Targets +```yaml +API Endpoints: +- Average: < 100ms +- P95: < 200ms +- P99: < 500ms + +Database Operations: +- Simple queries: < 10ms +- Complex queries: < 50ms +- Writes: < 20ms + +Cache Operations: +- Reads: < 5ms +- Writes: < 10ms +``` + +### 2. Throughput Targets +```yaml +API Layer: +- Minimum: 1000 requests/second +- Target: 5000 requests/second + +Database Layer: +- Reads: 10000 operations/second +- Writes: 1000 operations/second + +Cache Layer: +- Operations: 50000/second +``` + +### 3. Resource Usage Targets +```yaml +Memory: +- Base: < 100MB +- Under load: < 500MB +- Leak rate: < 1MB/hour + +CPU: +- Idle: < 5% +- Average load: < 40% +- Peak load: < 80% + +Connections: +- Database: < 100 concurrent +- Cache: < 1000 concurrent +- HTTP: < 10000 concurrent +``` + +## Next Steps + +1. Implement test helpers +2. Add more Laravel-style assertions +3. Create performance test suite +4. Add continuous benchmarking +5. Improve test coverage + +Would you like me to: +1. Create more test examples? +2. Add specific performance tests? +3. Create Laravel-compatible test helpers? diff --git a/docs/testing_package_specification.md b/docs/testing_package_specification.md new file mode 100644 index 0000000..347b75e --- /dev/null +++ b/docs/testing_package_specification.md @@ -0,0 +1,466 @@ +# Testing Package Specification + +## Overview + +The Testing package provides a robust testing framework that matches Laravel's testing functionality. It supports test case base classes, assertions, database testing, HTTP testing, and mocking while integrating with our Container and Event packages. + +> **Related Documentation** +> - See [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) for implementation status +> - See [Foundation Integration Guide](foundation_integration_guide.md) for integration patterns +> - See [Testing Guide](testing_guide.md) for testing approaches +> - See [Getting Started Guide](getting_started.md) for development setup +> - See [Container Package Specification](container_package_specification.md) for dependency injection +> - See [Events Package Specification](events_package_specification.md) for event testing + +## Core Features + +### 1. Test Case + +```dart +/// Base test case class +abstract class TestCase { + /// Container instance + late Container container; + + /// Application instance + late Application app; + + /// Event dispatcher + late EventDispatcherContract events; + + /// Sets up test case + @override + void setUp() { + container = Container(); + app = Application(container); + events = container.make(); + + setUpApplication(); + registerServices(); + } + + /// Sets up application + void setUpApplication() { + app.singleton((c) => app); + app.singleton((c) => container); + app.singleton((c) => events); + } + + /// Registers test services + void registerServices() {} + + /// Creates test instance + T make([dynamic parameters]) { + return container.make(parameters); + } + + /// Runs test in transaction + Future transaction(Future Function() callback) async { + var db = container.make(); + return await db.transaction(callback); + } + + /// Refreshes database + Future refreshDatabase() async { + await artisan.call('migrate:fresh'); + } + + /// Seeds database + Future seed([String? class]) async { + await artisan.call('db:seed', [ + if (class != null) '--class=$class' + ]); + } +} +``` + +### 2. HTTP Testing + +```dart +/// HTTP test case +abstract class HttpTestCase extends TestCase { + /// HTTP client + late TestClient client; + + @override + void setUp() { + super.setUp(); + client = TestClient(app); + } + + /// Makes GET request + Future get(String uri, { + Map? headers, + Map? query + }) { + return client.get(uri, headers: headers, query: query); + } + + /// Makes POST request + Future post(String uri, { + Map? headers, + dynamic body + }) { + return client.post(uri, headers: headers, body: body); + } + + /// Makes PUT request + Future put(String uri, { + Map? headers, + dynamic body + }) { + return client.put(uri, headers: headers, body: body); + } + + /// Makes DELETE request + Future delete(String uri, { + Map? headers + }) { + return client.delete(uri, headers: headers); + } + + /// Acts as user + Future actingAs(User user) async { + await auth.login(user); + } +} + +/// Test HTTP client +class TestClient { + /// Application instance + final Application app; + + TestClient(this.app); + + /// Makes HTTP request + Future request( + String method, + String uri, { + Map? headers, + dynamic body, + Map? query + }) async { + var request = Request(method, uri) + ..headers.addAll(headers ?? {}) + ..body = body + ..uri = uri.replace(queryParameters: query); + + var response = await app.handle(request); + return TestResponse(response); + } +} + +/// Test HTTP response +class TestResponse { + /// Response instance + final Response response; + + TestResponse(this.response); + + /// Asserts response status + void assertStatus(int status) { + expect(response.statusCode, equals(status)); + } + + /// Asserts response is OK + void assertOk() { + assertStatus(200); + } + + /// Asserts response is redirect + void assertRedirect([String? location]) { + expect(response.statusCode, inInclusiveRange(300, 399)); + if (location != null) { + expect(response.headers['location'], equals(location)); + } + } + + /// Asserts response contains JSON + void assertJson(Map json) { + expect(response.json(), equals(json)); + } + + /// Asserts response contains text + void assertSee(String text) { + expect(response.body, contains(text)); + } +} +``` + +### 3. Database Testing + +```dart +/// Database test case +abstract class DatabaseTestCase extends TestCase { + /// Database manager + late DatabaseManager db; + + @override + void setUp() { + super.setUp(); + db = container.make(); + } + + /// Seeds database + Future seed(String seeder) async { + await artisan.call('db:seed', ['--class=$seeder']); + } + + /// Asserts database has record + Future assertDatabaseHas( + String table, + Map data + ) async { + var count = await db.table(table) + .where(data) + .count(); + + expect(count, greaterThan(0)); + } + + /// Asserts database missing record + Future assertDatabaseMissing( + String table, + Map data + ) async { + var count = await db.table(table) + .where(data) + .count(); + + expect(count, equals(0)); + } + + /// Asserts database count + Future assertDatabaseCount( + String table, + int count + ) async { + var actual = await db.table(table).count(); + expect(actual, equals(count)); + } +} +``` + +### 4. Event Testing + +```dart +/// Event test case +abstract class EventTestCase extends TestCase { + /// Fake event dispatcher + late FakeEventDispatcher events; + + @override + void setUp() { + super.setUp(); + events = FakeEventDispatcher(); + container.instance(events); + } + + /// Asserts event dispatched + void assertDispatched(Type event, [Function? callback]) { + expect(events.dispatched(event), isTrue); + + if (callback != null) { + var dispatched = events.dispatched(event, callback); + expect(dispatched, isTrue); + } + } + + /// Asserts event not dispatched + void assertNotDispatched(Type event) { + expect(events.dispatched(event), isFalse); + } + + /// Asserts nothing dispatched + void assertNothingDispatched() { + expect(events.hasDispatched(), isFalse); + } +} + +/// Fake event dispatcher +class FakeEventDispatcher implements EventDispatcherContract { + /// Dispatched events + final List _events = []; + + @override + Future dispatch(T event) async { + _events.add(event); + } + + /// Checks if event dispatched + bool dispatched(Type event, [Function? callback]) { + var dispatched = _events.whereType(); + if (dispatched.isEmpty) return false; + + if (callback == null) return true; + + return dispatched.any((e) => callback(e)); + } + + /// Checks if any events dispatched + bool hasDispatched() => _events.isNotEmpty; +} +``` + +## Integration Examples + +### 1. HTTP Testing +```dart +class UserTest extends HttpTestCase { + test('creates user', () async { + var response = await post('/users', body: { + 'name': 'John Doe', + 'email': 'john@example.com' + }); + + response.assertStatus(201); + await assertDatabaseHas('users', { + 'email': 'john@example.com' + }); + }); + + test('requires authentication', () async { + var user = await User.factory().create(); + await actingAs(user); + + var response = await get('/dashboard'); + response.assertOk(); + }); +} +``` + +### 2. Database Testing +```dart +class OrderTest extends DatabaseTestCase { + test('creates order', () async { + await seed(ProductSeeder); + + var order = await Order.create({ + 'product_id': 1, + 'quantity': 5 + }); + + await assertDatabaseHas('orders', { + 'id': order.id, + 'quantity': 5 + }); + }); +} +``` + +### 3. Event Testing +```dart +class PaymentTest extends EventTestCase { + test('dispatches payment events', () async { + var payment = await processPayment(order); + + assertDispatched(PaymentProcessed, (event) { + return event.payment.id == payment.id; + }); + }); +} +``` + +## Testing + +```dart +void main() { + group('HTTP Testing', () { + test('makes requests', () async { + var client = TestClient(app); + + var response = await client.get('/users'); + + expect(response.statusCode, equals(200)); + expect(response.json(), isA()); + }); + + test('handles authentication', () async { + var case = UserTest(); + await case.setUp(); + + await case.actingAs(user); + var response = await case.get('/profile'); + + response.assertOk(); + }); + }); + + group('Database Testing', () { + test('seeds database', () async { + var case = OrderTest(); + await case.setUp(); + + await case.seed(ProductSeeder); + + await case.assertDatabaseCount('products', 10); + }); + }); +} +``` + +## Next Steps + +1. Implement core testing features +2. Add HTTP testing +3. Add database testing +4. Add event testing +5. Write tests +6. Add examples + +## Development Guidelines + +### 1. Getting Started +Before implementing testing features: +1. Review [Getting Started Guide](getting_started.md) +2. Check [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Follow [Testing Guide](testing_guide.md) +4. Use [Foundation Integration Guide](foundation_integration_guide.md) +5. Review [Container Package Specification](container_package_specification.md) +6. Review [Events Package Specification](events_package_specification.md) + +### 2. Implementation Process +For each testing feature: +1. Write tests following [Testing Guide](testing_guide.md) +2. Implement following Laravel patterns +3. Document following [Getting Started Guide](getting_started.md#documentation) +4. Integrate following [Foundation Integration Guide](foundation_integration_guide.md) + +### 3. Quality Requirements +All implementations must: +1. Pass all tests (see [Testing Guide](testing_guide.md)) +2. Meet Laravel compatibility requirements +3. Follow integration patterns (see [Foundation Integration Guide](foundation_integration_guide.md)) +4. Support dependency injection (see [Container Package Specification](container_package_specification.md)) +5. Support event testing (see [Events Package Specification](events_package_specification.md)) + +### 4. Integration Considerations +When implementing testing features: +1. Follow patterns in [Foundation Integration Guide](foundation_integration_guide.md) +2. Ensure Laravel compatibility per [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md) +3. Use testing approaches from [Testing Guide](testing_guide.md) +4. Follow development setup in [Getting Started Guide](getting_started.md) + +### 5. Performance Guidelines +Testing system must: +1. Execute tests efficiently +2. Support parallel testing +3. Handle large test suites +4. Manage test isolation +5. Meet performance targets in [Laravel Compatibility Roadmap](laravel_compatibility_roadmap.md#performance-benchmarks) + +### 6. Testing Requirements +Testing package tests must: +1. Cover all testing features +2. Test HTTP assertions +3. Verify database testing +4. Check event assertions +5. Follow patterns in [Testing Guide](testing_guide.md) + +### 7. Documentation Requirements +Testing documentation must: +1. Explain testing patterns +2. Show assertion examples +3. Cover test organization +4. Include performance tips +5. Follow standards in [Getting Started Guide](getting_started.md#documentation) diff --git a/melos.yaml b/melos.yaml index f4eeb3b..eada6fc 100644 --- a/melos.yaml +++ b/melos.yaml @@ -13,6 +13,7 @@ repository: https://github.com/protevus/platform packages: - apps/** - packages/** + - sandbox/** - helpers/tools/** - examples/** diff --git a/packages/bus/.gitignore b/packages/bus/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/bus/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/bus/CHANGELOG.md b/packages/bus/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/bus/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/bus/LICENSE.md b/packages/bus/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/bus/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/bus/README.md b/packages/bus/README.md new file mode 100644 index 0000000..757f4c9 --- /dev/null +++ b/packages/bus/README.md @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/bus/analysis_options.yaml b/packages/bus/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/bus/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/bus/lib/angel3_bus.dart b/packages/bus/lib/angel3_bus.dart new file mode 100644 index 0000000..536519b --- /dev/null +++ b/packages/bus/lib/angel3_bus.dart @@ -0,0 +1,9 @@ +library angel3_bus; + +export 'src/dispatcher.dart'; +export 'src/command.dart'; +export 'src/handler.dart'; +export 'src/queue.dart'; +export 'src/batch.dart'; +export 'src/chain.dart'; +export 'src/bus_service_provider.dart'; diff --git a/packages/bus/lib/src/batch.dart b/packages/bus/lib/src/batch.dart new file mode 100644 index 0000000..5a2a24f --- /dev/null +++ b/packages/bus/lib/src/batch.dart @@ -0,0 +1,19 @@ +import 'command.dart'; +import 'dispatcher.dart'; + +class Batch { + // Implement Batch +} + +class PendingBatch { + final Dispatcher _dispatcher; + final List _commands; + + PendingBatch(this._dispatcher, this._commands); + + Future dispatch() async { + for (var command in _commands) { + await _dispatcher.dispatch(command); + } + } +} diff --git a/packages/bus/lib/src/bus_service_provider.dart b/packages/bus/lib/src/bus_service_provider.dart new file mode 100644 index 0000000..d892653 --- /dev/null +++ b/packages/bus/lib/src/bus_service_provider.dart @@ -0,0 +1,60 @@ +// // lib/src/bus_service_provider.dart + +// import 'package:angel3_framework/angel3_framework.dart'; +// import 'package:angel3_event_bus/angel3_event_bus.dart'; +// import 'package:angel3_mq/angel3_mq.dart'; +// import 'dispatcher.dart'; + +// class BusServiceProvider extends Provider { +// @override +// Future boot(Angel app) async { +// // Register EventBus +// app.container.registerSingleton(EventBus()); + +// // Register Queue +// app.container.registerSingleton(MemoryQueue()); + +// // Create and register the Dispatcher +// final dispatcher = Dispatcher(app.container); +// app.container.registerSingleton(dispatcher); + +// // Register any global middleware or mappings +// dispatcher.pipeThrough([ +// // Add any global middleware here +// ]); + +// // Register command-to-handler mappings +// dispatcher.map({ +// // Add your command-to-handler mappings here +// // Example: ExampleCommand: ExampleCommandHandler, +// }); +// } +// } + +// class MemoryQueue implements Queue { +// final List _queue = []; + +// @override +// Future push(Command command) async { +// _queue.add(command); +// } + +// @override +// Future later(Duration delay, Command command) async { +// await Future.delayed(delay); +// _queue.add(command); +// } + +// @override +// Future pushOn(String queue, Command command) async { +// // For simplicity, ignoring the queue parameter in this implementation +// _queue.add(command); +// } + +// @override +// Future laterOn(String queue, Duration delay, Command command) async { +// // For simplicity, ignoring the queue parameter in this implementation +// await Future.delayed(delay); +// _queue.add(command); +// } +// } diff --git a/packages/bus/lib/src/chain.dart b/packages/bus/lib/src/chain.dart new file mode 100644 index 0000000..f46dc4e --- /dev/null +++ b/packages/bus/lib/src/chain.dart @@ -0,0 +1,15 @@ +import 'command.dart'; +import 'dispatcher.dart'; + +class PendingChain { + final Dispatcher _dispatcher; + final List _commands; + + PendingChain(this._dispatcher, this._commands); + + Future dispatch() async { + for (var command in _commands) { + await _dispatcher.dispatch(command); + } + } +} diff --git a/packages/bus/lib/src/command.dart b/packages/bus/lib/src/command.dart new file mode 100644 index 0000000..81d5395 --- /dev/null +++ b/packages/bus/lib/src/command.dart @@ -0,0 +1,5 @@ +// lib/src/command.dart + +abstract class Command {} + +abstract class ShouldQueue implements Command {} diff --git a/packages/bus/lib/src/dispatcher.dart b/packages/bus/lib/src/dispatcher.dart new file mode 100644 index 0000000..fcf1ea6 --- /dev/null +++ b/packages/bus/lib/src/dispatcher.dart @@ -0,0 +1,251 @@ +// lib/src/dispatcher.dart + +import 'dart:async'; + +import 'package:platform_container/container.dart'; +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:angel3_mq/mq.dart'; + +import 'command.dart'; +import 'handler.dart'; +import 'batch.dart'; +import 'chain.dart'; + +/// A class that handles dispatching and processing of commands. +/// +/// This dispatcher supports both synchronous and asynchronous command execution, +/// as well as queueing commands for later processing. +class Dispatcher implements QueueingDispatcher { + final Container container; + final EventBus _eventBus; + final Subject _commandSubject; + final MQClient _queue; + final Map _handlers = {}; + + /// Creates a new [Dispatcher] instance. + /// + /// [container] is used for dependency injection and to retrieve necessary services. + Dispatcher(this.container) + : _eventBus = container.make(), + _commandSubject = BehaviorSubject(), + _queue = container.make() { + _setupCommandProcessing(); + } + + /// Sets up the command processing pipeline. + /// + /// This method initializes the stream that processes commands and emits events. + void _setupCommandProcessing() { + _commandSubject + .flatMap((command) => Stream.fromFuture(_processCommand(command)) + .map((result) => CommandEvent(command, result: result)) + .onErrorReturnWith( + (error, stackTrace) => CommandEvent(command, error: error))) + .listen((event) { + _eventBus.fire(event); + }); + } + + /// Dispatches a command for execution. + /// + /// If the command implements [ShouldQueue], it will be dispatched to a queue. + /// Otherwise, it will be executed immediately. + /// + /// [command] is the command to be dispatched. + @override + Future dispatch(Command command) { + if (command is ShouldQueue) { + return dispatchToQueue(command); + } else { + return dispatchNow(command); + } + } + + /// Dispatches a command for immediate execution. + /// + /// [command] is the command to be executed. + /// [handler] is an optional specific handler for the command. + @override + Future dispatchNow(Command command, [Handler? handler]) { + final completer = Completer(); + _commandSubject.add(command); + + _eventBus + .on() + .where((event) => event.command == command) + .take(1) + .listen((event) { + if (event.error != null) { + completer.completeError(event.error); + } else { + completer.complete(event.result); + } + }); + + return completer.future; + } + + /// Processes a command by finding and executing its appropriate handler. + /// + /// [command] is the command to be processed. + Future _processCommand(Command command) async { + final handlerType = _handlers[command.runtimeType]; + if (handlerType != null) { + final handler = container.make(handlerType) as Handler; + return await handler.handle(command); + } else { + throw Exception('No handler found for command: ${command.runtimeType}'); + } + } + + /// Dispatches a command to a queue for later processing. + /// + /// [command] is the command to be queued. + @override + Future dispatchToQueue(Command command) async { + final message = Message( + payload: command, + headers: { + 'commandType': command.runtimeType.toString(), + }, + ); + _queue.sendMessage( + message: message, + // You might want to specify an exchange name and routing key if needed + // exchangeName: 'your_exchange_name', + // routingKey: 'your_routing_key', + ); + return message.id; + } + + /// Dispatches a command synchronously. + /// + /// This is an alias for [dispatchNow]. + /// + /// [command] is the command to be executed. + /// [handler] is an optional specific handler for the command. + @override + Future dispatchSync(Command command, [Handler? handler]) { + return dispatchNow(command, handler); + } + + /// Finds a batch by its ID. + /// + /// [batchId] is the ID of the batch to find. + @override + Future findBatch(String batchId) async { + // Implement batch finding logic + throw UnimplementedError(); + } + + /// Creates a new pending batch of commands. + /// + /// [commands] is the list of commands to be included in the batch. + @override + PendingBatch batch(List commands) { + return PendingBatch(this, commands); + } + + /// Creates a new pending chain of commands. + /// + /// [commands] is the list of commands to be included in the chain. + @override + PendingChain chain(List commands) { + return PendingChain(this, commands); + } + + /// Applies a list of pipes to the command processing pipeline. + /// + /// [pipes] is the list of pipes to be applied. + @override + Dispatcher pipeThrough(List pipes) { + _commandSubject.transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + var result = data; + for (var pipe in pipes) { + result = pipe(result); + } + sink.add(result); + }, + ), + ); + return this; + } + + /// Maps command types to their respective handler types. + /// + /// [handlers] is a map where keys are command types and values are handler types. + @override + Dispatcher map(Map handlers) { + _handlers.addAll(handlers); + return this; + } + + /// Dispatches a command to be executed after the current request-response cycle. + /// + /// [command] is the command to be dispatched after the response. + @override + void dispatchAfterResponse(Command command) { + final message = Message( + payload: command, + headers: { + 'commandType': command.runtimeType.toString(), + 'dispatchAfterResponse': 'true', + }, + ); + + _queue.sendMessage( + message: message, + // You might want to specify an exchange name if needed + // exchangeName: 'your_exchange_name', + // If you want to use a specific queue for after-response commands: + routingKey: 'after_response_queue', + ); + } +} + +abstract class QueueingDispatcher { + Future dispatch(Command command); + Future dispatchSync(Command command, [Handler? handler]); + Future dispatchNow(Command command, [Handler? handler]); + Future dispatchToQueue(Command command); + Future findBatch(String batchId); + PendingBatch batch(List commands); + PendingChain chain(List commands); + Dispatcher pipeThrough(List pipes); + Dispatcher map(Map handlers); + void dispatchAfterResponse(Command command); +} + +typedef Pipe = Command Function(Command); + +class CommandCompletedEvent extends AppEvent { + final dynamic result; + + CommandCompletedEvent(this.result); + + @override + List get props => [result]; +} + +class CommandErrorEvent extends AppEvent { + final dynamic error; + + CommandErrorEvent(this.error); + + @override + List get props => [error]; +} + +class CommandEvent extends AppEvent { + final Command command; + final dynamic result; + final dynamic error; + + CommandEvent(this.command, {this.result, this.error}); + + @override + List get props => [command, result, error]; +} diff --git a/packages/bus/lib/src/handler.dart b/packages/bus/lib/src/handler.dart new file mode 100644 index 0000000..1c8cdfe --- /dev/null +++ b/packages/bus/lib/src/handler.dart @@ -0,0 +1,5 @@ +import 'command.dart'; + +abstract class Handler { + Future handle(Command command); +} diff --git a/packages/bus/lib/src/queue.dart b/packages/bus/lib/src/queue.dart new file mode 100644 index 0000000..5d4b999 --- /dev/null +++ b/packages/bus/lib/src/queue.dart @@ -0,0 +1,8 @@ +import 'command.dart'; + +abstract class Queue { + Future push(Command command); + Future later(Duration delay, Command command); + Future pushOn(String queue, Command command); + Future laterOn(String queue, Duration delay, Command command); +} diff --git a/packages/bus/pubspec.yaml b/packages/bus/pubspec.yaml new file mode 100644 index 0000000..deb1887 --- /dev/null +++ b/packages/bus/pubspec.yaml @@ -0,0 +1,24 @@ +name: platform_bus +description: The Bus Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platformo + +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + platform_container: ^9.0.0 + platform_core: ^9.0.0 + angel3_reactivex: ^9.0.0 + angel3_event_bus: ^9.0.0 + angel3_mq: ^9.0.0 + # path: ^1.8.0 + +dev_dependencies: + build_runner: ^2.1.0 + lints: ^3.0.0 + mockito: ^5.3.0 + test: ^1.24.0 diff --git a/packages/bus/test/dispatcher_test.dart b/packages/bus/test/dispatcher_test.dart new file mode 100644 index 0000000..a931ffb --- /dev/null +++ b/packages/bus/test/dispatcher_test.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +import 'package:platform_bus/angel3_bus.dart'; +import 'package:platform_container/container.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:angel3_mq/mq.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class IsMessage extends Matcher { + @override + bool matches(item, Map matchState) => item is Message; + + @override + Description describe(Description description) => + description.add('is a Message'); +} + +class MockContainer extends Mock implements Container { + final Map _instances = {}; + + @override + T make([Type? type]) { + type ??= T; + return _instances[type] as T; + } + + void registerInstance(T instance) { + _instances[T] = instance; + } +} + +class MockEventBus extends Mock implements EventBus { + @override + Stream on() { + return super.noSuchMethod( + Invocation.method(#on, [], {#T: T}), + returnValue: Stream.empty(), + ) as Stream; + } +} + +class MockMQClient extends Mock implements MQClient { + Message? capturedMessage; + String? capturedExchangeName; + String? capturedRoutingKey; + + @override + dynamic noSuchMethod(Invocation invocation, + {Object? returnValue, Object? returnValueForMissingStub}) { + if (invocation.memberName == #sendMessage) { + final namedArgs = invocation.namedArguments; + capturedMessage = namedArgs[#message] as Message?; + capturedExchangeName = namedArgs[#exchangeName] as String?; + capturedRoutingKey = namedArgs[#routingKey] as String?; + return null; + } + return super.noSuchMethod(invocation, + returnValue: returnValue, + returnValueForMissingStub: returnValueForMissingStub); + } +} + +class TestCommand implements Command { + final String data; + TestCommand(this.data); +} + +class TestHandler implements Handler { + @override + Future handle(Command command) async { + if (command is TestCommand) { + return 'Handled: ${command.data}'; + } + throw UnimplementedError(); + } +} + +class TestQueuedCommand implements Command, ShouldQueue { + final String data; + TestQueuedCommand(this.data); +} + +void main() { + late MockContainer container; + late MockEventBus eventBus; + late MockMQClient mqClient; + late Dispatcher dispatcher; + + setUp(() { + container = MockContainer(); + eventBus = MockEventBus(); + mqClient = MockMQClient(); + + container.registerInstance(eventBus); + container.registerInstance(mqClient); + + dispatcher = Dispatcher(container); + }); + + group('Dispatcher', () { + test('dispatchNow should handle command and return result', () async { + final command = TestCommand('test data'); + final handler = TestHandler(); + + container.registerInstance(handler); + dispatcher.map({TestCommand: TestHandler}); + + final commandEventController = StreamController(); + when(eventBus.on()) + .thenAnswer((_) => commandEventController.stream); + + final future = dispatcher.dispatchNow(command); + + // Simulate the event firing + commandEventController + .add(CommandEvent(command, result: 'Handled: test data')); + + final result = await future; + expect(result, equals('Handled: test data')); + + await commandEventController.close(); + }); + + test('dispatch should handle regular commands immediately', () async { + final command = TestCommand('regular'); + final handler = TestHandler(); + + container.registerInstance(handler); + dispatcher.map({TestCommand: TestHandler}); + + final commandEventController = StreamController(); + when(eventBus.on()) + .thenAnswer((_) => commandEventController.stream); + + final future = dispatcher.dispatch(command); + + // Simulate the event firing + commandEventController + .add(CommandEvent(command, result: 'Handled: regular')); + + final result = await future; + expect(result, equals('Handled: regular')); + + await commandEventController.close(); + }); + + test('dispatch should queue ShouldQueue commands', () async { + final command = TestQueuedCommand('queued data'); + + // Dispatch the command + await dispatcher.dispatch(command); + + // Verify that sendMessage was called and check the message properties + expect(mqClient.capturedMessage, isNotNull); + expect(mqClient.capturedMessage!.payload, equals(command)); + expect(mqClient.capturedMessage!.headers?['commandType'], + equals('TestQueuedCommand')); + + // Optionally, verify exchange name and routing key if needed + expect(mqClient.capturedExchangeName, isNull); + expect(mqClient.capturedRoutingKey, isNull); + }); + + test( + 'dispatchAfterResponse should send message to queue with specific header', + () { + final command = TestCommand('after response data'); + + // Call dispatchAfterResponse + dispatcher.dispatchAfterResponse(command); + + // Verify that sendMessage was called and check the message properties + expect(mqClient.capturedMessage, isNotNull); + expect(mqClient.capturedMessage!.payload, equals(command)); + expect(mqClient.capturedMessage!.headers?['commandType'], + equals('TestCommand')); + expect(mqClient.capturedMessage!.headers?['dispatchAfterResponse'], + equals('true')); + + // Verify routing key + expect(mqClient.capturedRoutingKey, equals('after_response_queue')); + + // Optionally, verify exchange name if needed + expect(mqClient.capturedExchangeName, isNull); + }); + test('map should register command handlers', () { + dispatcher.map({TestCommand: TestHandler}); + + // Mock the event bus behavior for this test + when(eventBus.on()).thenAnswer((_) => Stream.empty()); + + // This test is a bit tricky to verify directly, but we can check if dispatch doesn't throw + expect(() => dispatcher.dispatch(TestCommand('test')), returnsNormally); + }); + }); +} diff --git a/packages/events/.gitignore b/packages/events/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/events/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/events/CHANGELOG.md b/packages/events/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/events/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/events/LICENSE.md b/packages/events/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/events/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 0000000..757f4c9 --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/events/analysis_options.yaml b/packages/events/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/events/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/events/lib/dispatcher.dart b/packages/events/lib/dispatcher.dart new file mode 100644 index 0000000..bbc26fc --- /dev/null +++ b/packages/events/lib/dispatcher.dart @@ -0,0 +1,3 @@ +library; + +export 'src/dispatcher.dart'; diff --git a/packages/events/lib/src/dispatcher.dart b/packages/events/lib/src/dispatcher.dart new file mode 100644 index 0000000..de9349e --- /dev/null +++ b/packages/events/lib/src/dispatcher.dart @@ -0,0 +1,499 @@ +import 'dart:async'; +import 'package:platform_container/container.dart'; +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:angel3_mq/mq.dart'; + +// Simulating some of the Laravel interfaces/classes +abstract class ShouldBroadcast {} + +abstract class ShouldQueue {} + +abstract class ShouldBeEncrypted {} + +abstract class ShouldDispatchAfterCommit {} + +class Dispatcher implements DispatcherContract { + // Properties as specified in YAML + final Container container; + final Map> _listeners = {}; + final Map> _wildcards = {}; + final Map> _wildcardsCache = {}; + late final Function _queueResolver; + late final Function _transactionManagerResolver; + final Map> _eventBusListeners = {}; + final Map> _untilCompleters = {}; + final Map _eventBusSubscriptions = {}; + final Set _processedMessageIds = {}; + + // Properties for Angel3 packages + final EventBus _eventBus; + late final MQClient? _mqClient; + final Map> _subjects = {}; + + // Queue and exchange names + static const String _eventsQueue = 'events_queue'; + static const String _delayedEventsQueue = 'delayed_events_queue'; + static const String _eventsExchange = 'events_exchange'; + + Dispatcher(this.container) : _eventBus = EventBus(); + + // Setter for _mqClient + set mqClient(MQClient client) { + _mqClient = client; + _setupQueuesAndExchanges(); + _startProcessingQueuedEvents(); + } + + void _setupQueuesAndExchanges() { + _mqClient?.declareQueue(_eventsQueue); + _mqClient?.declareQueue(_delayedEventsQueue); + _mqClient?.declareExchange( + exchangeName: _eventsExchange, + exchangeType: ExchangeType.direct, + ); + _mqClient?.bindQueue( + queueId: _eventsQueue, + exchangeName: _eventsExchange, + bindingKey: _eventsQueue, + ); + _mqClient?.bindQueue( + queueId: _delayedEventsQueue, + exchangeName: _eventsExchange, + bindingKey: _delayedEventsQueue, + ); + } + + void _startProcessingQueuedEvents() { + _mqClient?.fetchQueue(_eventsQueue).listen((Message message) async { + if (message.payload is Map) { + final eventData = message.payload as Map; + if (eventData.containsKey('event') && + eventData.containsKey('payload')) { + await dispatch(eventData['event'], eventData['payload']); + } else { + print('Invalid message format: ${message.payload}'); + } + } else { + print('Unexpected payload type: ${message.payload.runtimeType}'); + } + }); + } + + @override + void listen(dynamic events, dynamic listener) { + if (events is String) { + _addListener(events, listener); + } else if (events is List) { + for (var event in events) { + _addListener(event, listener); + } + } + if (events is String && events.contains('*')) { + _setupWildcardListen(events, listener); + } + } + + void _addListener(String event, dynamic listener) { + _listeners.putIfAbsent(event, () => []).add(listener); + + // Create a subject for this event if it doesn't exist + _subjects.putIfAbsent(event, () => BehaviorSubject()); + + // Add EventBus listener and store the subscription + final subscription = _eventBus.on().listen((AppEvent busEvent) { + if (busEvent is CustomAppEvent && busEvent.eventName == event) { + listener(event, busEvent.payload); + } + }); + _eventBusSubscriptions[event] = subscription; + } + + void _setupWildcardListen(String event, Function listener) { + _wildcards.putIfAbsent(event, () => []).add(listener); + _wildcardsCache.clear(); + } + + @override + bool hasListeners(String eventName) { + return _listeners.containsKey(eventName) || + _wildcards.containsKey(eventName) || + hasWildcardListeners(eventName); + } + + bool hasWildcardListeners(String eventName) { + return _wildcards.keys + .any((pattern) => _isWildcardMatch(pattern, eventName)); + } + + @override + void push(String event, [dynamic payload]) { + final effectivePayload = payload ?? []; + _mqClient?.sendMessage( + exchangeName: _eventsExchange, + routingKey: _delayedEventsQueue, + message: Message( + headers: {'expiration': '5000'}, // 5 seconds delay + payload: { + 'event': event, + 'payload': + effectivePayload is List ? effectivePayload : [effectivePayload], + }, + timestamp: DateTime.now().toIso8601String(), + id: 'msg_${DateTime.now().millisecondsSinceEpoch}', // Ensure unique ID + ), + ); + } + + @override + Future flush(String event) async { + final messageStream = _mqClient?.fetchQueue(_delayedEventsQueue); + if (messageStream == null) { + print('Warning: MQClient is not initialized'); + return; + } + + final messagesToProcess = []; + + // Collect messages to process + await for (final message in messageStream) { + print('Examining message: ${message.id}'); + if (message.payload is Map && + !_processedMessageIds.contains(message.id)) { + final eventData = message.payload as Map; + if (eventData['event'] == event) { + print('Adding message to process: ${message.id}'); + messagesToProcess.add(message); + } + } + } + + print('Total messages to process: ${messagesToProcess.length}'); + + // Process collected messages + for (final message in messagesToProcess) { + final eventData = message.payload as Map; + print('Processing message: ${message.id}'); + await dispatch(eventData['event'], eventData['payload']); + _mqClient?.deleteMessage(_delayedEventsQueue, message); + _processedMessageIds.add(message.id); + } + } + + @override + void subscribe(dynamic subscriber) { + if (subscriber is EventBusSubscriber) { + subscriber.subscribe(_eventBus); + } else { + // Handle other types of subscribers + } + } + + @override + Future until(dynamic event, [dynamic payload]) { + if (event is String) { + final completer = Completer(); + _untilCompleters[event] = completer; + + // Set up a one-time listener for this event + listen(event, (dynamic e, dynamic p) { + if (!completer.isCompleted) { + completer.complete(p); + _untilCompleters.remove(event); + } + }); + + // If payload is provided, dispatch the event immediately + if (payload != null) { + // Use dispatch instead of push to ensure immediate processing + dispatch(event, payload); + } + + return completer.future; + } + throw ArgumentError('Event must be a String'); + } + + @override + Future dispatch(dynamic event, [dynamic payload, bool? halt]) async { + final eventName = event is String ? event : event.runtimeType.toString(); + final eventPayload = payload ?? (event is AppEvent ? event : []); + + if (event is ShouldBroadcast || + (eventPayload is List && + eventPayload.isNotEmpty && + eventPayload[0] is ShouldBroadcast)) { + await _broadcastEvent(event); + } + + if (event is ShouldQueue || + (eventPayload is List && + eventPayload.isNotEmpty && + eventPayload[0] is ShouldQueue)) { + return _queueEvent(eventName, eventPayload); + } + + final listeners = getListeners(eventName); + for (var listener in listeners) { + final response = + await Function.apply(listener, [eventName, eventPayload]); + if (halt == true && response != null) { + return response; + } + if (response == false) { + break; + } + } + + return halt == true ? null : listeners; + } + + // void _addToSubject(String eventName, dynamic payload) { + // if (_subjects.containsKey(eventName)) { + // _subjects[eventName]!.add(payload); + // } + // } + + @override + List getListeners(String eventName) { + var listeners = [ + ...(_listeners[eventName] ?? []), + ...(_wildcardsCache[eventName] ?? _getWildcardListeners(eventName)), + ...(_eventBusListeners[eventName] ?? []), + ]; + + return listeners; + } + + List _getWildcardListeners(String eventName) { + final wildcardListeners = []; + for (var entry in _wildcards.entries) { + if (_isWildcardMatch(entry.key, eventName)) { + wildcardListeners.addAll(entry.value); + } + } + _wildcardsCache[eventName] = wildcardListeners; + return wildcardListeners; + } + + @override + void forget(String event) { + // Remove from _listeners + _listeners.remove(event); + + // Remove from _subjects + if (_subjects.containsKey(event)) { + _subjects[event]?.close(); + _subjects.remove(event); + } + + // Cancel and remove EventBus subscription + _eventBusSubscriptions[event]?.cancel(); + _eventBusSubscriptions.remove(event); + + // Remove from wildcards if applicable + if (event.contains('*')) { + _wildcards.remove(event); + _wildcardsCache.clear(); + } else { + // If it's not a wildcard, we need to remove it from any matching wildcard listeners + _wildcards.forEach((pattern, listeners) { + if (_isWildcardMatch(pattern, event)) { + _wildcards[pattern] = listeners + .where((listener) => listener != _listeners[event]) + .toList(); + } + }); + _wildcardsCache.clear(); + } + + // Remove any 'until' completers for this event + _untilCompleters.remove(event); + } + + @override + void forgetPushed() { + _listeners.removeWhere((key, _) => key.endsWith('_pushed')); + _eventBusListeners.removeWhere((key, _) => key.endsWith('_pushed')); + // Note: We're not clearing all EventBus listeners here, as that might affect other parts of your application + } + + @override + void setQueueResolver(Function resolver) { + _queueResolver = resolver; + } + + @override + void setTransactionManagerResolver(Function resolver) { + _transactionManagerResolver = resolver; + } + + // Add these methods for testing purposes + void triggerQueueResolver() { + _queueResolver(); + } + + void triggerTransactionManagerResolver() { + _transactionManagerResolver(); + } + + @override + Map> getRawListeners() { + return Map.unmodifiable(_listeners); + } + + bool _shouldBroadcast(List payload) { + return payload.isNotEmpty && payload[0] is ShouldBroadcast; + } + + Future _broadcastEvent(dynamic event) async { + // Implement broadcasting logic here + // For now, we'll just print a message + print('Broadcasting event: ${event.runtimeType}'); + } + + bool _isWildcardMatch(String pattern, String eventName) { + final regExp = RegExp('^${pattern.replaceAll('*', '.*')}\$'); + return regExp.hasMatch(eventName); + } + + bool _shouldQueue(List payload) { + return payload.isNotEmpty && payload[0] is ShouldQueue; + } + + Future _queueEvent(String eventName, dynamic payload) async { + _mqClient?.sendMessage( + exchangeName: _eventsExchange, + routingKey: _eventsQueue, + message: Message( + payload: {'event': eventName, 'payload': payload}, + timestamp: DateTime.now().toIso8601String(), + ), + ); + } + + // Updated on method + Stream on(String event) { + return (_subjects + .putIfAbsent(event, () => BehaviorSubject()) + .stream as Stream) + .where((event) => event is T) + .cast(); + } + + // In your Dispatcher class + void setMQClient(MQClient client) { + _mqClient = client; + } + + // Method to close the MQClient connection + Future close() async { + _mqClient?.close(); + } + + // Don't forget to close the subjects when they're no longer needed + void dispose() { + for (var subject in _subjects.values) { + subject.close(); + } + } +} +// ... rest of the code (DispatcherContract, EventBusSubscriber, etc.) remains the same + +abstract class DispatcherContract { + void listen(dynamic events, dynamic listener); + bool hasListeners(String eventName); + void push(String event, [dynamic payload]); + Future flush(String event); + void subscribe(dynamic subscriber); + Future until(dynamic event, [dynamic payload]); + Future dispatch(dynamic event, [dynamic payload, bool halt]); + List getListeners(String eventName); + void forget(String event); + void forgetPushed(); + void setQueueResolver(Function resolver); + void setTransactionManagerResolver(Function resolver); + Map> getRawListeners(); +} + +// Helper class for EventBus subscribers +abstract class EventBusSubscriber { + void subscribe(EventBus eventBus); +} + +// Mixin to simulate Macroable trait +mixin Macroable { + // Implementation of Macroable functionality +} + +// Mixin to simulate ReflectsClosures trait +mixin ReflectsClosures { + // Implementation of ReflectsClosures functionality +} + +// If not already defined, you might need to create an Event class +class Event { + final String name; + final dynamic data; + + Event(this.name, this.data); +} + +// Custom AppEvent subclasses for handling different event types +class StringBasedEvent extends AppEvent { + final String eventName; + final dynamic payload; + + StringBasedEvent(this.eventName, this.payload); + + @override + List get props => [eventName, payload]; +} + +class CustomAppEvent extends AppEvent { + final String eventName; + final dynamic payload; + + CustomAppEvent(this.eventName, this.payload); + + @override + List get props => [eventName, payload]; +} + +// This is a simple implementation of Reflector that does nothing +class EmptyReflector implements Reflector { + const EmptyReflector(); + + @override + ReflectedType reflectType(Type type) { + throw UnimplementedError(); + } + + @override + ReflectedInstance reflectInstance(Object object) { + throw UnimplementedError(); + } + + @override + ReflectedType reflectFutureOf(Type type) { + throw UnimplementedError(); + } + + @override + String? getName(Symbol symbol) { + // TODO: implement getName + throw UnimplementedError(); + } + + @override + ReflectedClass? reflectClass(Type clazz) { + // TODO: implement reflectClass + throw UnimplementedError(); + } + + @override + ReflectedFunction? reflectFunction(Function function) { + // TODO: implement reflectFunction + throw UnimplementedError(); + } +} diff --git a/packages/events/pubspec.yaml b/packages/events/pubspec.yaml new file mode 100644 index 0000000..22bde60 --- /dev/null +++ b/packages/events/pubspec.yaml @@ -0,0 +1,21 @@ +name: platform_events +description: The Events Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platformo +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + platform_container: ^9.0.0 + angel3_mq: ^9.0.0 + angel3_event_bus: ^9.0.0 + platform_core: ^9.0.0 + angel3_reactivex: ^0.27.5 + # path: ^1.8.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/events/test/event_test.dart b/packages/events/test/event_test.dart new file mode 100644 index 0000000..72a75ab --- /dev/null +++ b/packages/events/test/event_test.dart @@ -0,0 +1,430 @@ +import 'package:angel3_event_bus/res/app_event.dart'; +import 'package:test/test.dart'; +import 'package:platform_container/container.dart'; +import 'package:angel3_mq/mq.dart'; +import 'package:platform_events/dispatcher.dart'; // Replace with the actual import path + +void main() { + late Dispatcher dispatcher; + late MockMQClient mockMQClient; + + setUp(() { + var container = Container(EmptyReflector()); + dispatcher = Dispatcher(container); + mockMQClient = MockMQClient(); + dispatcher.mqClient = mockMQClient; // Use the setter + + // Clear the queue before each test + mockMQClient.queuedMessages.clear(); + }); + + group('Dispatcher', () { + test('listen and dispatch', () async { + var callCount = 0; + dispatcher.listen('test_event', (dynamic event, dynamic payload) { + expect(event, equals('test_event')); + expect(payload, equals(['test_payload'])); + callCount++; + }); + await dispatcher.dispatch('test_event', ['test_payload']); + expect(callCount, equals(1)); + }); + + test('wildcard listener', () async { + var callCount = 0; + dispatcher.listen('test.*', (dynamic event, dynamic payload) { + expect(event, matches(RegExp(r'^test\.'))); + callCount++; + }); + + await dispatcher.dispatch('test.one', ['payload1']); + await dispatcher.dispatch('test.two', ['payload2']); + expect(callCount, equals(2)); + }); + + test('hasListeners', () { + dispatcher.listen('test_event', (dynamic event, dynamic payload) {}); + expect(dispatcher.hasListeners('test_event'), isTrue); + expect(dispatcher.hasListeners('non_existent_event'), isFalse); + }); + + test('until', () async { + // Test without pushing the event immediately + var futureResult = dispatcher.until('test_event'); + + // Use a small delay to ensure the until listener is set up + await Future.delayed(Duration(milliseconds: 10)); + + await dispatcher.dispatch('test_event', ['test_payload']); + var result = await futureResult; + expect(result, equals(['test_payload'])); + + // Test with pushing the event immediately + result = + await dispatcher.until('another_test_event', ['another_payload']); + expect(result, equals(['another_payload'])); + }, timeout: Timeout(Duration(seconds: 5))); // Add a reasonable timeout + + test('forget', () async { + var callCount = 0; + dispatcher.listen('test_event', (dynamic event, dynamic payload) { + callCount++; + }); + await dispatcher.dispatch('test_event'); + expect(callCount, equals(1)); + + dispatcher.forget('test_event'); + await dispatcher.dispatch('test_event'); + expect(callCount, equals(1)); // Should not increase + }); + + test('push and flush', () async { + print('Starting push and flush test'); + + // Push 4 messages + for (var i = 0; i < 4; i++) { + dispatcher.push('delayed_event', ['delayed_payload_$i']); + } + + // Verify that 4 messages were queued + expect(mockMQClient.queuedMessages['delayed_events_queue']?.length, + equals(4), + reason: 'Should have queued exactly 4 messages'); + + print( + 'Queued messages: ${mockMQClient.queuedMessages['delayed_events_queue']?.length}'); + + var callCount = 0; + var processedPayloads = []; + + // Remove any existing listeners + dispatcher.forget('delayed_event'); + + dispatcher.listen('delayed_event', (dynamic event, dynamic payload) { + print('Listener called with payload: $payload'); + expect(event, equals('delayed_event')); + expect(payload[0], startsWith('delayed_payload_')); + processedPayloads.add(payload[0]); + callCount++; + }); + + await dispatcher.flush('delayed_event'); + + print('After flush - Call count: $callCount'); + print('Processed payloads: $processedPayloads'); + + expect(callCount, equals(4), reason: 'Should process exactly 4 messages'); + expect(processedPayloads.toSet().length, equals(4), + reason: 'All payloads should be unique'); + + // Verify that all messages were removed from the queue + expect(mockMQClient.queuedMessages['delayed_events_queue']?.length, + equals(0), + reason: 'Queue should be empty after flush'); + + // Flush again to ensure no more messages are processed + await dispatcher.flush('delayed_event'); + expect(callCount, equals(4), + reason: 'Should still be 4 after second flush'); + }); + + test('shouldBroadcast', () async { + var broadcastEvent = BroadcastTestEvent(); + var callCount = 0; + + dispatcher.listen('BroadcastTestEvent', (dynamic event, dynamic payload) { + callCount++; + }); + + await dispatcher.dispatch(broadcastEvent); + expect(callCount, equals(1)); + }); + + test('shouldQueue', () async { + var queueEvent = QueueTestEvent(); + await dispatcher.dispatch(queueEvent); + expect(mockMQClient.queuedMessages['events_queue'], isNotEmpty); + expect(mockMQClient.queuedMessages['events_queue']!.first.payload, + containsPair('event', 'QueueTestEvent')); + }); + + test('forgetPushed removes only pushed events', () { + dispatcher.listen('event_pushed', (_, __) {}); + dispatcher.listen('normal_event', (_, __) {}); + + dispatcher.forgetPushed(); + + expect(dispatcher.hasListeners('event_pushed'), isFalse); + expect(dispatcher.hasListeners('normal_event'), isTrue); + }); + + test('setQueueResolver and setTransactionManagerResolver', () { + var queueResolverCalled = false; + var transactionManagerResolverCalled = false; + + dispatcher.setQueueResolver(() { + queueResolverCalled = true; + }); + + dispatcher.setTransactionManagerResolver(() { + transactionManagerResolverCalled = true; + }); + + // Trigger the resolvers + dispatcher.triggerQueueResolver(); + dispatcher.triggerTransactionManagerResolver(); + + expect(queueResolverCalled, isTrue); + expect(transactionManagerResolverCalled, isTrue); + }); + + test('getRawListeners returns unmodifiable map', () { + dispatcher.listen('test_event', (_, __) {}); + var rawListeners = dispatcher.getRawListeners(); + + expect(rawListeners, isA>>()); + expect(() => rawListeners['new_event'] = [], throwsUnsupportedError); + }); + + test('multiple listeners for same event', () async { + var callCount1 = 0; + var callCount2 = 0; + + dispatcher.listen('multi_event', (_, __) => callCount1++); + dispatcher.listen('multi_event', (_, __) => callCount2++); + + await dispatcher.dispatch('multi_event'); + + expect(callCount1, equals(1)); + expect(callCount2, equals(1)); + }); + }); +} + +abstract class MQClientWrapper { + Stream fetchQueue(String queueId); + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }); + String declareQueue(String queueId); + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }); + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }); + void close(); +} + +class RealMQClientWrapper implements MQClientWrapper { + final MQClient _client; + + RealMQClientWrapper(this._client); + + @override + Stream fetchQueue(String queueId) => _client.fetchQueue(queueId); + + @override + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }) => + _client.sendMessage( + message: message, + exchangeName: exchangeName, + routingKey: routingKey, + ); + + @override + String declareQueue(String queueId) => _client.declareQueue(queueId); + + @override + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }) => + _client.declareExchange( + exchangeName: exchangeName, + exchangeType: exchangeType, + ); + + @override + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) => + _client.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: bindingKey, + ); + + @override + void close() => _client.close(); +} + +class MockMQClient implements MQClient { + Map> queuedMessages = {}; + int _messageIdCounter = 0; + + void queueMessage(String queueName, Message message) { + queuedMessages.putIfAbsent(queueName, () => []).add(message); + print( + 'Queued message. Queue $queueName now has ${queuedMessages[queueName]?.length} messages'); + } + + @override + String declareQueue(String queueId) { + queuedMessages[queueId] = []; + return queueId; + } + + @override + void deleteQueue(String queueId) { + queuedMessages.remove(queueId); + } + + @override + Stream fetchQueue(String queueId) { + print('Fetching queue: $queueId'); + return Stream.fromIterable(queuedMessages[queueId] ?? []); + } + + @override + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }) { + print('Sending message to queue: $routingKey'); + final newMessage = Message( + payload: message.payload, + headers: message.headers, + timestamp: message.timestamp, + id: 'msg_${_messageIdCounter++}', + ); + queueMessage(routingKey ?? '', newMessage); + } + + @override + Message? getLatestMessage(String queueId) { + final messages = queuedMessages[queueId]; + return messages?.isNotEmpty == true ? messages!.last : null; + } + + @override + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + // Implement if needed for your tests + } + + @override + void unbindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + // Implement if needed for your tests + } + + @override + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }) { + // Implement if needed for your tests + } + + @override + void deleteExchange(String exchangeName) { + // Implement if needed for your tests + } + + @override + List listQueues() { + return queuedMessages.keys.toList(); + } + + @override + void close() { + queuedMessages.clear(); + } + + @override + void deleteMessage(String queueId, Message message) { + print('Deleting message from queue: $queueId'); + queuedMessages[queueId]?.removeWhere((m) => m.id == message.id); + print( + 'After deletion, queue $queueId has ${queuedMessages[queueId]?.length} messages'); + } +} + +class BroadcastTestEvent implements AppEvent, ShouldBroadcast { + @override + List get props => []; + + @override + bool? get stringify => true; + + @override + DateTime get timestamp => DateTime.now(); +} + +class QueueTestEvent implements AppEvent, ShouldQueue { + @override + List get props => []; + + @override + bool? get stringify => true; + + @override + DateTime get timestamp => DateTime.now(); +} + +// This is a simple implementation of Reflector that does nothing +class EmptyReflector implements Reflector { + const EmptyReflector(); + + @override + ReflectedType reflectType(Type type) { + throw UnimplementedError(); + } + + @override + ReflectedInstance reflectInstance(Object object) { + throw UnimplementedError(); + } + + @override + ReflectedType reflectFutureOf(Type type) { + throw UnimplementedError(); + } + + @override + String? getName(Symbol symbol) { + // TODO: implement getName + throw UnimplementedError(); + } + + @override + ReflectedClass? reflectClass(Type clazz) { + // TODO: implement reflectClass + throw UnimplementedError(); + } + + @override + ReflectedFunction? reflectFunction(Function function) { + // TODO: implement reflectFunction + throw UnimplementedError(); + } +} diff --git a/packages/pipeline/.gitignore b/packages/pipeline/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/pipeline/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/pipeline/CHANGELOG.md b/packages/pipeline/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/pipeline/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/pipeline/LICENSE.md b/packages/pipeline/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/pipeline/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md new file mode 100644 index 0000000..586a156 --- /dev/null +++ b/packages/pipeline/README.md @@ -0,0 +1,380 @@ +

+ +# Platform Pipeline + +A Laravel-compatible pipeline implementation in Dart, providing a robust way to pass objects through a series of operations. + +[![Pub Version](https://img.shields.io/pub/v/platform_pipeline)]() +[![Build Status](https://img.shields.io/github/workflow/status/platform/pipeline/tests)]() + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) + - [Basic Usage](#basic-usage) + - [Class-Based Pipes](#class-based-pipes) + - [Invokable Classes](#invokable-classes) + - [Using Different Method Names](#using-different-method-names) + - [Passing Parameters to Pipes](#passing-parameters-to-pipes) + - [Early Pipeline Termination](#early-pipeline-termination) + - [Conditional Pipeline Execution](#conditional-pipeline-execution) +- [Advanced Usage](#advanced-usage) + - [Working with Objects](#working-with-objects) + - [Async Operations](#async-operations) +- [Laravel API Compatibility](#laravel-api-compatibility) +- [Comparison with Laravel](#comparison-with-laravel) +- [Troubleshooting](#troubleshooting) +- [Testing](#testing) +- [Contributing](#contributing) +- [License](#license) + +## Overview + +Platform Pipeline is a 100% API-compatible port of Laravel's Pipeline to Dart. It allows you to pass an object through a series of operations (pipes) in a fluent, maintainable way. Each pipe can examine, modify, or replace the object before passing it to the next pipe in the sequence. + +## Features + +- 💯 100% Laravel Pipeline API compatibility +- 🔄 Support for class-based and callable pipes +- 🎯 Dependency injection through container integration +- ⚡ Async operation support +- 🔀 Conditional pipeline execution +- 🎭 Method name customization via `via()` +- 🎁 Parameter passing to pipes +- 🛑 Early pipeline termination +- 🧪 Comprehensive test coverage + +## Requirements + +- Dart SDK: >=2.17.0 <4.0.0 +- platform_container: ^1.0.0 + +## Installation + +Add this to your package's `pubspec.yaml` file: + +```yaml +dependencies: + platform_pipeline: ^1.0.0 +``` + +## Usage + +### Basic Usage + +```dart +import 'package:platform_pipeline/pipeline.dart'; +import 'package:platform_container/container.dart'; + +void main() async { + // Create a container instance + var container = Container(); + + // Create a pipeline + var result = await Pipeline(container) + .send('Hello') + .through([ + (String value, next) => next(value + ' World'), + (String value, next) => next(value + '!'), + ]) + .then((value) => value); + + print(result); // Outputs: Hello World! +} +``` + +### Class-Based Pipes + +```dart +class UppercasePipe { + Future handle(String value, Function next) async { + return next(value.toUpperCase()); + } +} + +class AddExclamationPipe { + Future handle(String value, Function next) async { + return next(value + '!'); + } +} + +void main() async { + var container = Container(); + + var result = await Pipeline(container) + .send('hello') + .through([ + UppercasePipe(), + AddExclamationPipe(), + ]) + .then((value) => value); + + print(result); // Outputs: HELLO! +} +``` + +### Invokable Classes + +```dart +class TransformPipe { + Future call(String value, Function next) async { + return next(value.toUpperCase()); + } +} + +void main() async { + var container = Container(); + + var result = await Pipeline(container) + .send('hello') + .through([TransformPipe()]) + .then((value) => value); + + print(result); // Outputs: HELLO +} +``` + +### Using Different Method Names + +```dart +class CustomPipe { + Future transform(String value, Function next) async { + return next(value.toUpperCase()); + } +} + +void main() async { + var container = Container(); + + var result = await Pipeline(container) + .send('hello') + .through([CustomPipe()]) + .via('transform') + .then((value) => value); + + print(result); // Outputs: HELLO +} +``` + +### Passing Parameters to Pipes + +```dart +class PrefixPipe { + Future handle( + String value, + Function next, [ + String prefix = '', + ]) async { + return next('$prefix$value'); + } +} + +void main() async { + var container = Container(); + container.registerFactory((c) => PrefixPipe()); + + var pipeline = Pipeline(container); + pipeline.registerPipeType('PrefixPipe', PrefixPipe); + + var result = await pipeline + .send('World') + .through('PrefixPipe:Hello ') + .then((value) => value); + + print(result); // Outputs: Hello World +} +``` + +### Early Pipeline Termination + +```dart +void main() async { + var container = Container(); + + var result = await Pipeline(container) + .send('hello') + .through([ + (value, next) => 'TERMINATED', // Pipeline stops here + (value, next) => next('NEVER REACHED'), + ]) + .then((value) => value); + + print(result); // Outputs: TERMINATED +} +``` + +### Conditional Pipeline Execution + +```dart +void main() async { + var container = Container(); + var shouldTransform = true; + + var result = await Pipeline(container) + .send('hello') + .when(() => shouldTransform, (Pipeline pipeline) { + pipeline.pipe([ + (value, next) => next(value.toUpperCase()), + ]); + }) + .then((value) => value); + + print(result); // Outputs: HELLO +} +``` + +## Advanced Usage + +### Working with Objects + +```dart +class User { + String name; + int age; + + User(this.name, this.age); +} + +class AgeValidationPipe { + Future handle(User user, Function next) async { + if (user.age < 18) { + throw Exception('User must be 18 or older'); + } + return next(user); + } +} + +class NameFormattingPipe { + Future handle(User user, Function next) async { + user.name = user.name.trim().toLowerCase(); + return next(user); + } +} + +void main() async { + var container = Container(); + + var user = User('John Doe ', 20); + + try { + user = await Pipeline(container) + .send(user) + .through([ + AgeValidationPipe(), + NameFormattingPipe(), + ]) + .then((value) => value); + + print('${user.name} is ${user.age} years old'); + // Outputs: john doe is 20 years old + } catch (e) { + print('Validation failed: $e'); + } +} +``` + +### Async Operations + +```dart +class AsyncTransformPipe { + Future handle(String value, Function next) async { + // Simulate async operation + await Future.delayed(Duration(seconds: 1)); + return next(value.toUpperCase()); + } +} + +void main() async { + var container = Container(); + + var result = await Pipeline(container) + .send('hello') + .through([AsyncTransformPipe()]) + .then((value) => value); + + print(result); // Outputs after 1 second: HELLO +} +``` + +## Laravel API Compatibility + +This package maintains 100% API compatibility with Laravel's Pipeline implementation. All Laravel Pipeline features are supported: + +- `send()` - Set the object being passed through the pipeline +- `through()` - Set the array of pipes +- `pipe()` - Push additional pipes onto the pipeline +- `via()` - Set the method to call on the pipes +- `then()` - Run the pipeline with a final destination callback +- `thenReturn()` - Run the pipeline and return the result + +## Comparison with Laravel + +| Feature | Laravel | Platform Pipeline | +|---------|---------|------------------| +| API Methods | ✓ | ✓ | +| Container Integration | ✓ | ✓ | +| Pipe Types | Class, Callable | Class, Callable | +| Async Support | ✗ | ✓ | +| Type Safety | ✗ | ✓ | +| Parameter Passing | ✓ | ✓ | +| Early Termination | ✓ | ✓ | +| Method Customization | ✓ | ✓ | +| Conditional Execution | ✓ | ✓ | + +## Troubleshooting + +### Common Issues + +1. Container Not Provided +```dart +// ❌ Wrong +var pipeline = Pipeline(null); + +// ✓ Correct +var container = Container(); +var pipeline = Pipeline(container); +``` + +2. Missing Type Registration +```dart +// ❌ Wrong +pipeline.through('CustomPipe:param'); + +// ✓ Correct +pipeline.registerPipeType('CustomPipe', CustomPipe); +pipeline.through('CustomPipe:param'); +``` + +3. Incorrect Method Name +```dart +// ❌ Wrong +class CustomPipe { + void process(value, next) {} // Wrong method name +} + +// ✓ Correct +class CustomPipe { + void handle(value, next) {} // Default method name +} +// Or specify the method name: +pipeline.via('process').through([CustomPipe()]); +``` + +## Testing + +Run the tests with: + +```bash +dart test +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This package is open-sourced software licensed under the MIT license. diff --git a/packages/pipeline/analysis_options.yaml b/packages/pipeline/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/pipeline/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/pipeline/examples/async_pipeline.dart b/packages/pipeline/examples/async_pipeline.dart new file mode 100644 index 0000000..03f6dc1 --- /dev/null +++ b/packages/pipeline/examples/async_pipeline.dart @@ -0,0 +1,38 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_pipeline/pipeline.dart'; + +class AsyncGreetingPipe { + Future handle(String input, Function next) async { + await Future.delayed(Duration(seconds: 1)); + return next('Hello, $input'); + } +} + +class AsyncExclamationPipe { + Future handle(String input, Function next) async { + await Future.delayed(Duration(seconds: 1)); + return next('$input!'); + } +} + +void main() async { + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + app.container.registerSingleton((c) => Pipeline(c)); + + app.get('/', (req, res) async { + var pipeline = app.container.make(); + var result = await pipeline + .send('World') + .through(['AsyncGreetingPipe', 'AsyncExclamationPipe']).then( + (result) => result.toUpperCase()); + + res.write(result); // Outputs: "HELLO, WORLD!" (after 2 seconds) + }); + + await http.startServer('localhost', 3000); + print('Server started on http://localhost:3000'); +} diff --git a/packages/pipeline/examples/basic_usage.dart b/packages/pipeline/examples/basic_usage.dart new file mode 100644 index 0000000..736a354 --- /dev/null +++ b/packages/pipeline/examples/basic_usage.dart @@ -0,0 +1,36 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_pipeline/pipeline.dart'; + +class GreetingPipe { + dynamic handle(String input, Function next) { + return next('Hello, $input'); + } +} + +class ExclamationPipe { + dynamic handle(String input, Function next) { + return next('$input!'); + } +} + +void main() async { + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + app.container.registerSingleton((c) => Pipeline(c)); + + app.get('/', (req, res) async { + var pipeline = app.container.make(); + var result = await pipeline + .send('World') + .through(['GreetingPipe', 'ExclamationPipe']).then( + (result) => result.toUpperCase()); + + res.write(result); // Outputs: "HELLO, WORLD!" + }); + + await http.startServer('localhost', 3000); + print('Server started on http://localhost:3000'); +} diff --git a/packages/pipeline/examples/error_handling.dart b/packages/pipeline/examples/error_handling.dart new file mode 100644 index 0000000..6f8aa84 --- /dev/null +++ b/packages/pipeline/examples/error_handling.dart @@ -0,0 +1,34 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_pipeline/pipeline.dart'; + +class ErrorPipe { + dynamic handle(String input, Function next) { + throw Exception('Simulated error'); + } +} + +void main() async { + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + app.container.registerSingleton((c) => Pipeline(c)); + + app.get('/', (req, res) async { + var pipeline = app.container.make(); + try { + await pipeline + .send('World') + .through(['ErrorPipe']).then((result) => result.toUpperCase()); + } catch (e) { + res.write('Error occurred: ${e.toString()}'); + return; + } + + res.write('This should not be reached'); + }); + + await http.startServer('localhost', 3000); + print('Server started on http://localhost:3000'); +} diff --git a/packages/pipeline/examples/mixed_pipes.dart b/packages/pipeline/examples/mixed_pipes.dart new file mode 100644 index 0000000..0e17a71 --- /dev/null +++ b/packages/pipeline/examples/mixed_pipes.dart @@ -0,0 +1,35 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_pipeline/pipeline.dart'; + +class GreetingPipe { + dynamic handle(String input, Function next) { + return next('Hello, $input'); + } +} + +void main() async { + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + app.container.registerSingleton((c) => Pipeline(c)); + + app.get('/', (req, res) async { + var pipeline = app.container.make(); + var result = await pipeline.send('World').through([ + 'GreetingPipe', + (String input, Function next) => next('$input!'), + (String input, Function next) async { + await Future.delayed(Duration(seconds: 1)); + return next(input.toUpperCase()); + }, + ]).then((result) => 'Final result: $result'); + + res.write( + result); // Outputs: "Final result: HELLO, WORLD!" (after 1 second) + }); + + await http.startServer('localhost', 3000); + print('Server started on http://localhost:3000'); +} diff --git a/packages/pipeline/lib/pipeline.dart b/packages/pipeline/lib/pipeline.dart new file mode 100644 index 0000000..9b73f9f --- /dev/null +++ b/packages/pipeline/lib/pipeline.dart @@ -0,0 +1,5 @@ +library; + +export 'src/pipeline.dart'; +export 'src/conditionable.dart'; +export 'src/pipeline_contract.dart'; diff --git a/packages/pipeline/lib/src/conditionable.dart b/packages/pipeline/lib/src/conditionable.dart new file mode 100644 index 0000000..ce796a0 --- /dev/null +++ b/packages/pipeline/lib/src/conditionable.dart @@ -0,0 +1,16 @@ +/// Provides conditional execution methods for the pipeline. +mixin Conditionable { + T when(bool Function() callback, void Function(T) callback2) { + if (callback()) { + callback2(this as T); + } + return this as T; + } + + T unless(bool Function() callback, void Function(T) callback2) { + if (!callback()) { + callback2(this as T); + } + return this as T; + } +} diff --git a/packages/pipeline/lib/src/pipeline.dart b/packages/pipeline/lib/src/pipeline.dart new file mode 100644 index 0000000..37d9d68 --- /dev/null +++ b/packages/pipeline/lib/src/pipeline.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'package:platform_container/container.dart'; +import 'package:logging/logging.dart'; +import 'pipeline_contract.dart'; +import 'conditionable.dart'; + +/// Defines the signature for a pipe function. +typedef PipeFunction = FutureOr Function( + dynamic passable, FutureOr Function(dynamic) next); + +/// The primary class for building and executing pipelines. +class Pipeline with Conditionable implements PipelineContract { + /// The container implementation. + Container? _container; + + final Map _typeMap = {}; + + /// The object being passed through the pipeline. + dynamic _passable; + + /// The array of class pipes. + final List _pipes = []; + + /// The method to call on each pipe. + String _method = 'handle'; + + /// Logger for the pipeline. + final Logger _logger = Logger('Pipeline'); + + /// Create a new class instance. + Pipeline(this._container); + + void registerPipeType(String name, Type type) { + _typeMap[name] = type; + } + + /// Set the object being sent through the pipeline. + @override + Pipeline send(dynamic passable) { + _passable = passable; + return this; + } + + /// Set the array of pipes. + @override + Pipeline through(dynamic pipes) { + if (_container == null) { + throw Exception( + 'A container instance has not been passed to the Pipeline.'); + } + _pipes.addAll(pipes is Iterable ? pipes.toList() : [pipes]); + return this; + } + + /// Push additional pipes onto the pipeline. + @override + Pipeline pipe(dynamic pipes) { + if (_container == null) { + throw Exception( + 'A container instance has not been passed to the Pipeline.'); + } + _pipes.addAll(pipes is Iterable ? pipes.toList() : [pipes]); + return this; + } + + /// Set the method to call on the pipes. + @override + Pipeline via(String method) { + _method = method; + return this; + } + + /// Run the pipeline with a final destination callback. + @override + Future then(FutureOr Function(dynamic) destination) async { + if (_container == null) { + throw Exception( + 'A container instance has not been passed to the Pipeline.'); + } + + var pipeline = (dynamic passable) async => await destination(passable); + + for (var pipe in _pipes.reversed) { + var next = pipeline; + pipeline = (dynamic passable) async { + return await carry(pipe, passable, next); + }; + } + + return await pipeline(_passable); + } + + /// Run the pipeline and return the result. + @override + Future thenReturn() async { + return then((passable) => passable); + } + + /// Get a Closure that represents a slice of the application onion. + Future carry(dynamic pipe, dynamic passable, Function next) async { + try { + if (pipe is Function) { + return await pipe(passable, next); + } + + if (pipe is String) { + if (_container == null) { + throw Exception('Container is null, cannot resolve pipe: $pipe'); + } + + final parts = parsePipeString(pipe); + final pipeClass = parts[0]; + final parameters = parts.length > 1 ? parts.sublist(1) : []; + + Type? pipeType; + if (_typeMap.containsKey(pipeClass)) { + pipeType = _typeMap[pipeClass]; + } else { + // Try to resolve from mirrors + try { + for (var lib in currentMirrorSystem().libraries.values) { + for (var decl in lib.declarations.values) { + if (decl is ClassMirror && + decl.simpleName == Symbol(pipeClass)) { + pipeType = decl.reflectedType; + break; + } + } + if (pipeType != null) break; + } + } catch (_) {} + + if (pipeType == null) { + throw Exception('Type not registered for pipe: $pipe'); + } + } + + var instance = _container?.make(pipeType); + if (instance == null) { + throw Exception('Unable to resolve pipe: $pipe'); + } + + return await invokeMethod( + instance, _method, [passable, next, ...parameters]); + } + + if (pipe is Type) { + if (_container == null) { + throw Exception('Container is null, cannot resolve pipe type'); + } + + var instance = _container?.make(pipe); + if (instance == null) { + throw Exception('Unable to resolve pipe type: $pipe'); + } + + return await invokeMethod(instance, _method, [passable, next]); + } + + // Handle instance of a class + if (pipe is Object) { + return await invokeMethod(pipe, _method, [passable, next]); + } + + throw Exception('Unsupported pipe type: ${pipe.runtimeType}'); + } catch (e) { + return handleException(passable, e); + } + } + + /// Parse full pipe string to get name and parameters. + List parsePipeString(String pipe) { + var parts = pipe.split(':'); + return [parts[0], if (parts.length > 1) ...parts[1].split(',')]; + } + + /// Get the array of configured pipes. + List pipes() { + return List.unmodifiable(_pipes); + } + + /// Get the container instance. + Container getContainer() { + if (_container == null) { + throw Exception( + 'A container instance has not been passed to the Pipeline.'); + } + return _container!; + } + + /// Set the container instance. + Pipeline setContainer(Container container) { + _container = container; + return this; + } + + /// Handle the value returned from each pipe before passing it to the next. + dynamic handleCarry(dynamic carry) { + if (carry is Future) { + return carry.then((value) => value ?? _passable); + } + return carry ?? _passable; + } + + Future invokeMethod( + dynamic instance, String methodName, List arguments) async { + // First try call() for invokable objects + if (instance is Function) { + return await instance(arguments[0], arguments[1]); + } + + var instanceMirror = reflect(instance); + + // Check for call method first (invokable objects) + var callSymbol = Symbol('call'); + if (instanceMirror.type.declarations.containsKey(callSymbol)) { + var result = instanceMirror.invoke(callSymbol, arguments); + return await result.reflectee; + } + + // Then try the specified method + var methodSymbol = Symbol(methodName); + if (!instanceMirror.type.declarations.containsKey(methodSymbol)) { + throw Exception('Method $methodName not found on instance: $instance'); + } + + var result = instanceMirror.invoke(methodSymbol, arguments); + return await result.reflectee; + } + + /// Handle the given exception. + dynamic handleException(dynamic passable, Object e) { + if (e is Exception && e.toString().contains('Container is null')) { + throw Exception( + 'A container instance has not been passed to the Pipeline.'); + } + _logger.severe('Exception occurred in pipeline', e); + throw e; + } +} diff --git a/packages/pipeline/lib/src/pipeline_contract.dart b/packages/pipeline/lib/src/pipeline_contract.dart new file mode 100644 index 0000000..2b45e7f --- /dev/null +++ b/packages/pipeline/lib/src/pipeline_contract.dart @@ -0,0 +1,9 @@ +/// Represents a series of "pipes" through which an object can be passed. +abstract class PipelineContract { + PipelineContract send(dynamic passable); + PipelineContract through(dynamic pipes); + PipelineContract pipe(dynamic pipes); + PipelineContract via(String method); + Future then(dynamic Function(dynamic) destination); + Future thenReturn(); +} diff --git a/packages/pipeline/pubspec.yaml b/packages/pipeline/pubspec.yaml new file mode 100644 index 0000000..92c1efb --- /dev/null +++ b/packages/pipeline/pubspec.yaml @@ -0,0 +1,19 @@ +name: platform_pipeline +description: The Pipeline Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platform + +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + platform_container: ^9.0.0 + platform_core: ^9.0.0 + logging: ^1.1.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/pipeline/test/laravel_pipeline_test.dart b/packages/pipeline/test/laravel_pipeline_test.dart new file mode 100644 index 0000000..0fcdeaa --- /dev/null +++ b/packages/pipeline/test/laravel_pipeline_test.dart @@ -0,0 +1,258 @@ +import 'package:platform_container/container.dart'; +import 'package:platform_pipeline/pipeline.dart'; +import 'package:test/test.dart'; + +// Test pipe classes to match Laravel's test classes +class PipelineTestPipeOne { + static String? testPipeOne; + + Future handle(dynamic piped, Function next) async { + testPipeOne = piped.toString(); + return next(piped); + } + + Future differentMethod(dynamic piped, Function next) async { + return next(piped); + } +} + +class PipelineTestPipeTwo { + static String? testPipeOne; + + Future call(dynamic piped, Function next) async { + testPipeOne = piped.toString(); + return next(piped); + } +} + +class PipelineTestParameterPipe { + static List? testParameters; + + Future handle(dynamic piped, Function next, + [String? parameter1, String? parameter2]) async { + testParameters = [ + if (parameter1 != null) parameter1, + if (parameter2 != null) parameter2 + ]; + return next(piped); + } +} + +void main() { + group('Laravel Pipeline Tests', () { + late Container container; + late Pipeline pipeline; + + setUp(() { + container = Container(const EmptyReflector()); + pipeline = Pipeline(container); + + // Register test classes with container + container + .registerFactory((c) => PipelineTestPipeOne()); + container + .registerFactory((c) => PipelineTestPipeTwo()); + container.registerFactory( + (c) => PipelineTestParameterPipe()); + + // Register types with pipeline + pipeline.registerPipeType('PipelineTestPipeOne', PipelineTestPipeOne); + pipeline.registerPipeType('PipelineTestPipeTwo', PipelineTestPipeTwo); + pipeline.registerPipeType( + 'PipelineTestParameterPipe', PipelineTestParameterPipe); + + // Reset static test variables + PipelineTestPipeOne.testPipeOne = null; + PipelineTestPipeTwo.testPipeOne = null; + PipelineTestParameterPipe.testParameters = null; + }); + + test('Pipeline basic usage', () async { + String? testPipeTwo; + final pipeTwo = (dynamic piped, Function next) { + testPipeTwo = piped.toString(); + return next(piped); + }; + + final result = await Pipeline(container) + .send('foo') + .through([PipelineTestPipeOne(), pipeTwo]).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeOne.testPipeOne, equals('foo')); + expect(testPipeTwo, equals('foo')); + }); + + test('Pipeline usage with objects', () async { + final result = await Pipeline(container) + .send('foo') + .through([PipelineTestPipeOne()]).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeOne.testPipeOne, equals('foo')); + }); + + test('Pipeline usage with invokable objects', () async { + final result = await Pipeline(container) + .send('foo') + .through([PipelineTestPipeTwo()]).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeTwo.testPipeOne, equals('foo')); + }); + + test('Pipeline usage with callable', () async { + String? testPipeOne; + final function = (dynamic piped, Function next) { + testPipeOne = 'foo'; + return next(piped); + }; + + var result = await Pipeline(container) + .send('foo') + .through([function]).then((piped) => piped); + + expect(result, equals('foo')); + expect(testPipeOne, equals('foo')); + + testPipeOne = null; + + result = + await Pipeline(container).send('bar').through(function).thenReturn(); + + expect(result, equals('bar')); + expect(testPipeOne, equals('foo')); + }); + + test('Pipeline usage with pipe', () async { + final object = {'value': 0}; + + final function = (dynamic obj, Function next) { + obj['value']++; + return next(obj); + }; + + final result = await Pipeline(container) + .send(object) + .through([function]).pipe([function]).then((piped) => piped); + + expect(result, equals(object)); + expect(object['value'], equals(2)); + }); + + test('Pipeline usage with invokable class', () async { + final result = await Pipeline(container) + .send('foo') + .through([PipelineTestPipeTwo()]).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeTwo.testPipeOne, equals('foo')); + }); + + test('Then method is not called if the pipe returns', () async { + String thenValue = '(*_*)'; + String secondValue = '(*_*)'; + + final result = await Pipeline(container).send('foo').through([ + (value, next) => 'm(-_-)m', + (value, next) { + secondValue = 'm(-_-)m'; + return next(value); + }, + ]).then((piped) { + thenValue = '(0_0)'; + return piped; + }); + + expect(result, equals('m(-_-)m')); + // The then callback is not called + expect(thenValue, equals('(*_*)')); + // The second pipe is not called + expect(secondValue, equals('(*_*)')); + }); + + test('Then method input value', () async { + String? pipeReturn; + String? thenArg; + + final result = await Pipeline(container).send('foo').through([ + (value, next) async { + final nextValue = await next('::not_foo::'); + pipeReturn = nextValue; + return 'pipe::$nextValue'; + } + ]).then((piped) { + thenArg = piped; + return 'then$piped'; + }); + + expect(result, equals('pipe::then::not_foo::')); + expect(thenArg, equals('::not_foo::')); + }); + + test('Pipeline usage with parameters', () async { + final parameters = ['one', 'two']; + + final result = await Pipeline(container) + .send('foo') + .through('PipelineTestParameterPipe:${parameters.join(',')}') + .then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestParameterPipe.testParameters, equals(parameters)); + }); + + test('Pipeline via changes the method being called on the pipes', () async { + final result = await Pipeline(container) + .send('data') + .through(PipelineTestPipeOne()) + .via('differentMethod') + .then((piped) => piped); + + expect(result, equals('data')); + }); + + test('Pipeline throws exception on resolve without container', () async { + expect( + () => Pipeline(null) + .send('data') + .through(PipelineTestPipeOne()) + .then((piped) => piped), + throwsA(isA().having( + (e) => e.toString(), + 'message', + contains( + 'A container instance has not been passed to the Pipeline')))); + }); + + test('Pipeline thenReturn method runs pipeline then returns passable', + () async { + final result = await Pipeline(container) + .send('foo') + .through([PipelineTestPipeOne()]).thenReturn(); + + expect(result, equals('foo')); + expect(PipelineTestPipeOne.testPipeOne, equals('foo')); + }); + + test('Pipeline conditionable', () async { + var result = await Pipeline(container).send('foo').when(() => true, + (Pipeline pipeline) { + pipeline.pipe([PipelineTestPipeOne()]); + }).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeOne.testPipeOne, equals('foo')); + + PipelineTestPipeOne.testPipeOne = null; + + result = await Pipeline(container).send('foo').when(() => false, + (Pipeline pipeline) { + pipeline.pipe([PipelineTestPipeOne()]); + }).then((piped) => piped); + + expect(result, equals('foo')); + expect(PipelineTestPipeOne.testPipeOne, isNull); + }); + }); +} diff --git a/packages/pipeline/test/pipeline_test.dart b/packages/pipeline/test/pipeline_test.dart new file mode 100644 index 0000000..ae67501 --- /dev/null +++ b/packages/pipeline/test/pipeline_test.dart @@ -0,0 +1,106 @@ +import 'package:test/test.dart'; +import 'package:platform_core/core.dart'; +import 'package:platform_container/container.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_pipeline/pipeline.dart'; + +class AddExclamationPipe { + Future handle(String input, Function next) async { + return await next('$input!'); + } +} + +class UppercasePipe { + Future handle(String input, Function next) async { + return await next(input.toUpperCase()); + } +} + +void main() { + late Application app; + late Container container; + late Pipeline pipeline; + + setUp(() { + app = Application(reflector: MirrorsReflector()); + container = app.container; + container.registerSingleton(AddExclamationPipe()); + container.registerSingleton(UppercasePipe()); + pipeline = Pipeline(container); + pipeline.registerPipeType('AddExclamationPipe', AddExclamationPipe); + pipeline.registerPipeType('UppercasePipe', UppercasePipe); + }); + + test('Pipeline should process simple string pipes', () async { + var result = await pipeline.send('hello').through( + ['AddExclamationPipe', 'UppercasePipe']).then((res) async => res); + expect(result, equals('HELLO!')); + }); + + test('Pipeline should process function pipes', () async { + var result = await pipeline.send('hello').through([ + (String input, Function next) async { + var result = await next('$input, WORLD'); + return result; + }, + (String input, Function next) async { + var result = await next(input.toUpperCase()); + return result; + }, + ]).then((res) async => res as String); + + expect(result, equals('HELLO, WORLD')); + }); + + test('Pipeline should handle mixed pipe types', () async { + var result = await pipeline.send('hello').through([ + 'AddExclamationPipe', + (String input, Function next) async { + var result = await next(input.toUpperCase()); + return result; + }, + ]).then((res) async => res as String); + expect(result, equals('HELLO!')); + }); + + test('Pipeline should handle async pipes', () async { + var result = await pipeline.send('hello').through([ + 'UppercasePipe', + (String input, Function next) async { + await Future.delayed(Duration(milliseconds: 100)); + return next('$input, WORLD'); + }, + ]).then((res) async => res as String); + expect(result, equals('HELLO, WORLD')); + }); + + test('Pipeline should throw exception for unresolvable pipe', () { + expect( + () => pipeline + .send('hello') + .through(['NonExistentPipe']).then((res) => res), + throwsA(isA()), + ); + }); + + test('Pipeline should allow chaining of pipes', () async { + var result = await pipeline + .send('hello') + .pipe('AddExclamationPipe') + .pipe('UppercasePipe') + .then((res) async => res as String); + expect(result, equals('HELLO!')); + }); + + test('Pipeline should respect the order of pipes', () async { + var result1 = await pipeline + .send('hello') + .through(['AddExclamationPipe', 'UppercasePipe']).then((res) => res); + var result2 = await pipeline + .send('hello') + .through(['UppercasePipe', 'AddExclamationPipe']).then((res) => res); + expect(result1, equals('HELLO!')); + expect(result2, equals('HELLO!!')); + expect(result1, isNot(equals(result2))); + }); +} diff --git a/packages/process/.gitignore b/packages/process/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/process/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/process/CHANGELOG.md b/packages/process/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/process/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/process/LICENSE.md b/packages/process/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/process/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/process/README.md b/packages/process/README.md new file mode 100644 index 0000000..757f4c9 --- /dev/null +++ b/packages/process/README.md @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/process/analysis_options.yaml b/packages/process/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/process/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/process/examples/basic_process/main.dart b/packages/process/examples/basic_process/main.dart new file mode 100644 index 0000000..8415927 --- /dev/null +++ b/packages/process/examples/basic_process/main.dart @@ -0,0 +1,36 @@ +// examples/basic_process/main.dart +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_process/angel3_process.dart'; +import 'package:logging/logging.dart'; +import 'package:platform_container/mirrors.dart'; + +void main() async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + + // Create an Angel application with MirrorsReflector + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + // Use dependency injection for ProcessManager + app.container.registerSingleton(ProcessManager()); + + app.get('/', (req, res) async { + // Use the ioc function to get the ProcessManager instance + var processManager = await req.container?.make(); + + var process = await processManager?.start( + 'example_process', + 'echo', + ['Hello, Angel3 Process!'], + ); + var result = await process?.run(); + res.writeln('Process output: ${result?.output.trim()}'); + }); + + await http.startServer('localhost', 3000); + print('Server listening at http://localhost:3000'); +} diff --git a/packages/process/examples/process_pipeline/main.dart b/packages/process/examples/process_pipeline/main.dart new file mode 100644 index 0000000..7950162 --- /dev/null +++ b/packages/process/examples/process_pipeline/main.dart @@ -0,0 +1,37 @@ +// examples/process_pipeline/main.dart +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_process/angel3_process.dart'; +import 'package:logging/logging.dart'; +import 'package:platform_container/mirrors.dart'; + +void main() async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + + // Create an Angel application with MirrorsReflector + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + // Register ProcessManager as a singleton in the container + app.container.registerSingleton(ProcessManager()); + + app.get('/', (req, res) async { + // Use dependency injection to get the ProcessManager instance + var processManager = await req.container?.make(); + + var processes = [ + angel3Process('echo', ['Hello']), + angel3Process('sed', ['s/Hello/Greetings/']), + angel3Process('tr', ['[:lower:]', '[:upper:]']), + ]; + + var result = await processManager?.pipeline(processes); + res.writeln('Pipeline output: ${result?.output.trim()}'); + }); + + await http.startServer('localhost', 3000); + print('Server listening at http://localhost:3000'); +} diff --git a/packages/process/examples/process_pool/main.dart b/packages/process/examples/process_pool/main.dart new file mode 100644 index 0000000..f8f21e4 --- /dev/null +++ b/packages/process/examples/process_pool/main.dart @@ -0,0 +1,37 @@ +// examples/process_pool/main.dart +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_process/angel3_process.dart'; +import 'package:logging/logging.dart'; +import 'package:platform_container/mirrors.dart'; + +void main() async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + + // Create an Angel application with MirrorsReflector + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + // Register ProcessManager as a singleton in the container + app.container.registerSingleton(ProcessManager()); + + app.get('/', (req, res) async { + // Use dependency injection to get the ProcessManager instance + var processManager = await req.container?.make(); + + var processes = + List.generate(5, (index) => angel3Process('echo', ['Process $index'])); + var results = await processManager?.pool(processes, concurrency: 3); + var output = results + ?.map((result) => + '${result.process.command} output: ${result.output.trim()}') + .join('\n'); + res.write(output); + }); + + await http.startServer('localhost', 3000); + print('Server listening at http://localhost:3000'); +} diff --git a/packages/process/examples/web_server_with_processes/main.dart b/packages/process/examples/web_server_with_processes/main.dart new file mode 100644 index 0000000..1559db1 --- /dev/null +++ b/packages/process/examples/web_server_with_processes/main.dart @@ -0,0 +1,67 @@ +// examples/web_server_with_processes/main.dart +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_process/angel3_process.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; +import 'package:angel3_mustache/angel3_mustache.dart'; +import 'package:platform_container/mirrors.dart'; + +void main() async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + + // Create an Angel application with MirrorsReflector + var app = Application(reflector: MirrorsReflector()); + var http = PlatformHttp(app); + + // Register dependencies in the container + app.container.registerSingleton(const LocalFileSystem()); + app.container.registerSingleton(ProcessManager()); + + // Set up the view renderer + var fs = await app.container.make(); + var viewsDirectory = fs.directory('views'); + //await app.configure(mustache(viewsDirectory)); + + app.get('/', (req, res) async { + await res.render('index'); + }); + + app.post('/run-process', (req, res) async { + var body = await req.bodyAsMap; + var command = body['command'] as String?; + var args = (body['args'] as String?)?.split(' ') ?? []; + + if (command == null || command.isEmpty) { + throw PlatformHttpException.badRequest(message: 'Command is required'); + } + + // Use dependency injection to get the ProcessManager instance + var processManager = await req.container?.make(); + + var process = await processManager?.start( + 'user_process', + command, + args, + ); + var result = await process?.run(); + + await res.json({ + 'output': result?.output.trim(), + 'exitCode': result?.exitCode, + }); + }); + + app.fallback((req, res) => throw PlatformHttpException.notFound()); + + app.errorHandler = (e, req, res) { + res.writeln('Error: ${e.message}'); + return false; + }; + + await http.startServer('localhost', 3000); + print('Server listening at http://localhost:3000'); +} diff --git a/packages/process/examples/web_server_with_processes/views/index.mustache b/packages/process/examples/web_server_with_processes/views/index.mustache new file mode 100644 index 0000000..cb29244 --- /dev/null +++ b/packages/process/examples/web_server_with_processes/views/index.mustache @@ -0,0 +1,39 @@ + + + + + + + Angel3 Process Example + + +

Run a Process

+
+ + +
+ + +
+ +
+
+ + + + diff --git a/packages/process/lib/angel3_process.dart b/packages/process/lib/angel3_process.dart new file mode 100644 index 0000000..1965d4a --- /dev/null +++ b/packages/process/lib/angel3_process.dart @@ -0,0 +1,7 @@ +library; + +export 'src/process.dart'; +export 'src/process_helper.dart'; +export 'src/process_manager.dart'; +export 'src/process_pipeline.dart'; +export 'src/process_pool.dart'; diff --git a/packages/process/lib/src/process.dart b/packages/process/lib/src/process.dart new file mode 100644 index 0000000..4049683 --- /dev/null +++ b/packages/process/lib/src/process.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; + +// import 'package:angel3_framework/angel3_framework.dart'; +// import 'package:angel3_mq/mq.dart'; +// import 'package:angel3_reactivex/angel3_reactivex.dart'; +// import 'package:angel3_event_bus/event_bus.dart'; +import 'package:logging/logging.dart'; + +class Angel3Process { + final String _command; + final List _arguments; + final String? _workingDirectory; + final Map? _environment; + final Duration? _timeout; + final bool _tty; + final bool _enableReadError; + final Logger _logger; + + late final StreamController> _outputController; + late final StreamController> _errorController; + late final Completer _outputCompleter; + late final Completer _errorCompleter; + final Completer _errorOutputCompleter = Completer(); + bool _isOutputComplete = false; + bool _isErrorComplete = false; + + Process? _process; + DateTime? _startTime; + DateTime? _endTime; + bool _isDisposed = false; + + Angel3Process( + this._command, + this._arguments, { + String? workingDirectory, + Map? environment, + Duration? timeout, + bool tty = false, + bool enableReadError = true, + Logger? logger, + }) : _workingDirectory = workingDirectory, + _environment = environment, + _timeout = timeout, + _tty = tty, + _enableReadError = enableReadError, + _logger = logger ?? Logger('Angel3Process'), + _outputController = StreamController>.broadcast(), + _errorController = StreamController>.broadcast(), + _outputCompleter = Completer(), + _errorCompleter = Completer(); + + // Add this public getter + String get command => _command; + int? get pid => _process?.pid; + DateTime? get startTime => _startTime; + DateTime? get endTime => _endTime; + + Stream> get output => _outputController.stream; + Stream> get errorOutput => _errorController.stream; + + // Future get outputAsString => _outputCompleter.future; + // Future get errorOutputAsString => _errorCompleter.future; + + Future get exitCode => _process?.exitCode ?? Future.value(-1); + bool get isRunning => _process != null && !_process!.kill(); + + Future start() async { + if (_isDisposed) { + throw StateError('This process has been disposed and cannot be reused.'); + } + _startTime = DateTime.now(); + + try { + _process = await Process.start( + _command, + _arguments, + workingDirectory: _workingDirectory, + environment: _environment, + runInShell: _tty, + ); + + _process!.stdout.listen( + (data) { + _outputController.add(data); + }, + onDone: () { + if (!_isOutputComplete) { + _isOutputComplete = true; + _outputController.close(); + } + }, + onError: (error) { + _logger.severe('Error in stdout stream', error); + _outputController.addError(error); + if (!_isOutputComplete) { + _isOutputComplete = true; + _outputController.close(); + } + }, + ); + + var errorBuffer = StringBuffer(); + _process!.stderr.listen( + (data) { + _errorController.add(data); + errorBuffer.write(utf8.decode(data)); + }, + onDone: () { + if (!_isErrorComplete) { + _isErrorComplete = true; + _errorController.close(); + _errorOutputCompleter.complete(errorBuffer.toString()); + } + }, + onError: (error) { + _logger.severe('Error in stderr stream', error); + _errorController.addError(error); + if (!_isErrorComplete) { + _isErrorComplete = true; + _errorController.close(); + _errorOutputCompleter.completeError(error); + } + }, + ); + + _logger.info('Process started: $_command ${_arguments.join(' ')}'); + } catch (e) { + _logger.severe('Failed to start process', e); + rethrow; + } + return this; + } + + Future run() async { + await start(); + if (_timeout != null) { + return await runWithTimeout(_timeout!); + } + final exitCode = await this.exitCode; + final output = await outputAsString; + final errorOutput = await _errorOutputCompleter.future; + _endTime = DateTime.now(); + return ProcessResult(pid!, exitCode, output, errorOutput); + } + + Future runWithTimeout(Duration timeout) async { + final exitCodeFuture = this.exitCode.timeout(timeout, onTimeout: () { + kill(); + throw TimeoutException('Process timed out', timeout); + }); + + try { + final exitCode = await exitCodeFuture; + final output = await outputAsString; + final errorOutput = await _errorOutputCompleter.future; + _endTime = DateTime.now(); + return ProcessResult(pid!, exitCode, output, errorOutput); + } catch (e) { + if (e is TimeoutException) { + throw e; + } + rethrow; + } + } + + Future write(String input) async { + if (_process != null) { + _process!.stdin.write(input); + await _process!.stdin.flush(); + } else { + throw StateError('Process has not been started'); + } + } + + Future writeLines(List lines) async { + for (final line in lines) { + await write('$line\n'); + } + } + + Future kill({ProcessSignal signal = ProcessSignal.sigterm}) async { + if (_process != null) { + _logger.info('Killing process with signal: ${signal.name}'); + final result = _process!.kill(signal); + if (!result) { + _logger.warning('Failed to kill process with signal: ${signal.name}'); + } + } + } + + bool sendSignal(ProcessSignal signal) { + return _process?.kill(signal) ?? false; + } + + Future dispose() async { + if (!_isDisposed) { + _isDisposed = true; + await _outputController.close(); + await _errorController.close(); + if (!_outputCompleter.isCompleted) { + _outputCompleter.complete(''); + } + if (!_errorCompleter.isCompleted) { + _errorCompleter.complete(''); + } + await kill(); + _logger.info('Process disposed: $_command ${_arguments.join(' ')}'); + } + } + + Future get outputAsString async { + var buffer = await output.transform(utf8.decoder).join(); + return buffer; + } + + Future get errorOutputAsString => _errorOutputCompleter.future; +} + +class ProcessResult { + final int pid; + final int exitCode; + final String output; + final String errorOutput; + + ProcessResult(this.pid, this.exitCode, this.output, this.errorOutput); + + @override + String toString() { + return 'ProcessResult(pid: $pid, exitCode: $exitCode, output: ${output.length} chars, errorOutput: ${errorOutput.length} chars)'; + } +} + +class InvokedProcess { + final Angel3Process process; + final DateTime startTime; + final DateTime endTime; + final int exitCode; + final String output; + final String errorOutput; + + InvokedProcess(this.process, this.startTime, this.endTime, this.exitCode, + this.output, this.errorOutput); + + @override + String toString() { + return 'InvokedProcess(command: ${process._command}, arguments: ${process._arguments}, startTime: $startTime, endTime: $endTime, exitCode: $exitCode)'; + } +} diff --git a/packages/process/lib/src/process_helper.dart b/packages/process/lib/src/process_helper.dart new file mode 100644 index 0000000..5166698 --- /dev/null +++ b/packages/process/lib/src/process_helper.dart @@ -0,0 +1,21 @@ +import 'process.dart'; + +Angel3Process angel3Process( + String command, + List arguments, { + String? workingDirectory, + Map? environment, + Duration? timeout, + bool tty = false, + bool enableReadError = true, +}) { + return Angel3Process( + command, + arguments, + workingDirectory: workingDirectory, + environment: environment, + timeout: timeout, + tty: tty, + enableReadError: enableReadError, + ); +} diff --git a/packages/process/lib/src/process_manager.dart b/packages/process/lib/src/process_manager.dart new file mode 100644 index 0000000..f2d48b6 --- /dev/null +++ b/packages/process/lib/src/process_manager.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +// import 'package:angel3_framework/angel3_framework.dart'; +// import 'package:angel3_mq/mq.dart'; +// import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:logging/logging.dart'; + +import 'process.dart'; +import 'process_pool.dart'; +import 'process_pipeline.dart'; + +class ProcessManager { + final Map _processes = {}; + final EventBus _eventBus = EventBus(); + final List _subscriptions = []; + final Logger _logger = Logger('ProcessManager'); + + Future start( + String id, + String command, + List arguments, { + String? workingDirectory, + Map? environment, + Duration? timeout, + bool tty = false, + bool enableReadError = true, + }) async { + if (_processes.containsKey(id)) { + throw Exception('Process with id $id already exists'); + } + + final process = Angel3Process( + command, + arguments, + workingDirectory: workingDirectory, + environment: environment, + timeout: timeout, + tty: tty, + enableReadError: enableReadError, + logger: Logger('Angel3Process:$id'), + ); + + try { + await process.start(); + _processes[id] = process; + + _eventBus.fire(ProcessStartedEvent(id, process) as AppEvent); + + process.exitCode.then((exitCode) { + _eventBus.fire(ProcessExitedEvent(id, exitCode) as AppEvent); + _processes.remove(id); + }); + + _logger.info('Started process with id: $id'); + return process; + } catch (e) { + _logger.severe('Failed to start process with id: $id', e); + rethrow; + } + } + + Angel3Process? get(String id) => _processes[id]; + + Future kill(String id, + {ProcessSignal signal = ProcessSignal.sigterm}) async { + final process = _processes[id]; + if (process != null) { + await process.kill(signal: signal); + _processes.remove(id); + _logger.info('Killed process with id: $id'); + } else { + _logger.warning('Attempted to kill non-existent process with id: $id'); + } + } + + Future killAll({ProcessSignal signal = ProcessSignal.sigterm}) async { + _logger.info('Killing all processes'); + await Future.wait( + _processes.values.map((process) => process.kill(signal: signal))); + _processes.clear(); + } + + Stream get events => _eventBus.on(); + + Future> pool(List processes, + {int concurrency = 5}) async { + _logger.info('Running process pool with concurrency: $concurrency'); + final pool = ProcessPool(concurrency: concurrency); + return await pool.run(processes); + } + + Future pipeline(List processes) async { + _logger.info('Running process pipeline'); + final pipeline = ProcessPipeline(processes); + return await pipeline.run(); + } + + void dispose() { + _logger.info('Disposing ProcessManager'); + + // Cancel all event subscriptions + for (var subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); + + // Dispose all processes + for (var process in _processes.values) { + process.dispose(); + } + _processes.clear(); + + _logger.info('ProcessManager disposed'); + } +} + +abstract class ProcessEvent extends AppEvent {} + +class ProcessStartedEvent extends ProcessEvent { + final String id; + final Angel3Process process; + + ProcessStartedEvent(this.id, this.process); + + @override + String toString() => + 'ProcessStartedEvent(id: $id, command: ${process.command})'; + + @override + // TODO: implement props + List get props => throw UnimplementedError(); +} + +class ProcessExitedEvent extends ProcessEvent { + final String id; + final int exitCode; + + ProcessExitedEvent(this.id, this.exitCode); + + @override + String toString() => 'ProcessExitedEvent(id: $id, exitCode: $exitCode)'; + + @override + // TODO: implement props + List get props => throw UnimplementedError(); +} diff --git a/packages/process/lib/src/process_pipeline.dart b/packages/process/lib/src/process_pipeline.dart new file mode 100644 index 0000000..449c708 --- /dev/null +++ b/packages/process/lib/src/process_pipeline.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'package:logging/logging.dart'; +import 'process.dart'; + +class ProcessPipeline { + final List _processes; + final Logger _logger = Logger('ProcessPipeline'); + + ProcessPipeline(this._processes); + + Future run() async { + String input = ''; + DateTime startTime = DateTime.now(); + DateTime endTime; + int lastExitCode = 0; + + _logger + .info('Starting process pipeline with ${_processes.length} processes'); + + for (final process in _processes) { + _logger.info('Running process: ${process.command}'); + if (input.isNotEmpty) { + await process.write(input); + } + final result = await process.run(); + input = result.output; + lastExitCode = result.exitCode; + _logger.info( + 'Process completed: ${process.command} with exit code $lastExitCode'); + if (lastExitCode != 0) { + _logger.warning( + 'Pipeline stopped due to non-zero exit code: $lastExitCode'); + break; + } + } + + endTime = DateTime.now(); + _logger.info( + 'Pipeline completed. Total duration: ${endTime.difference(startTime)}'); + + return InvokedProcess( + _processes.last, + startTime, + endTime, + lastExitCode, + input, + '', + ); + } +} diff --git a/packages/process/lib/src/process_pool.dart b/packages/process/lib/src/process_pool.dart new file mode 100644 index 0000000..67b323f --- /dev/null +++ b/packages/process/lib/src/process_pool.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'package:logging/logging.dart'; +import 'process.dart'; + +class ProcessPool { + final int concurrency; + final List _queue = []; + int _running = 0; + final Logger _logger = Logger('ProcessPool'); + + ProcessPool({this.concurrency = 5}); + + Future> run(List processes) async { + final results = []; + final completer = Completer>(); + + _logger.info('Starting process pool with ${processes.length} processes'); + + for (final process in processes) { + _queue.add(() async { + try { + final result = await _runProcess(process); + results.add(result); + } catch (e) { + _logger.severe('Error running process in pool', e); + } finally { + _running--; + _processQueue(); + if (_running == 0 && _queue.isEmpty) { + completer.complete(results); + } + } + }); + } + + _processQueue(); + + return completer.future; + } + + void _processQueue() { + while (_running < concurrency && _queue.isNotEmpty) { + _running++; + _queue.removeAt(0)(); + } + } + + Future _runProcess(Angel3Process process) async { + _logger.info('Running process: ${process.command}'); + final result = await process.run(); + _logger.info( + 'Process completed: ${process.command} with exit code ${result.exitCode}'); + return InvokedProcess( + process, + process.startTime!, + process.endTime!, + result.exitCode, + result.output, + result.errorOutput, + ); + } +} diff --git a/packages/process/lib/src/process_service_provider.dart b/packages/process/lib/src/process_service_provider.dart new file mode 100644 index 0000000..c762c94 --- /dev/null +++ b/packages/process/lib/src/process_service_provider.dart @@ -0,0 +1,24 @@ +/* import 'package:angel3_framework/angel3_framework.dart'; +import 'package:logging/logging.dart'; +import 'process_manager.dart'; + +class ProcessServiceProvider extends Provider { + final Logger _logger = Logger('ProcessServiceProvider'); + + @override + void registers() { + container.singleton((_) => ProcessManager()); + _logger.info('Registered ProcessManager'); + } + + @override + void boots(Angel app) { + app.shutdownHooks.add((_) async { + _logger.info('Shutting down ProcessManager'); + final processManager = app.container.make(); + await processManager.killAll(); + processManager.dispose(); + }); + _logger.info('Added ProcessManager shutdown hook'); + } +} */ diff --git a/packages/process/pubspec.yaml b/packages/process/pubspec.yaml new file mode 100644 index 0000000..b837bae --- /dev/null +++ b/packages/process/pubspec.yaml @@ -0,0 +1,25 @@ +name: platform_process +description: The Process Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platformo + +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + platform_container: ^8.0.0 + platform_core: ^8.0.0 + angel3_mq: ^8.0.0 + angel3_mustache: ^8.0.0 + angel3_event_bus: ^8.0.0 + angel3_reactivex: ^8.0.0 + file: ^7.0.0 + logging: ^1.1.0 + path: ^1.8.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/process/test/process_test.dart b/packages/process/test/process_test.dart new file mode 100644 index 0000000..ab3a7eb --- /dev/null +++ b/packages/process/test/process_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:platform_process/angel3_process.dart'; +import 'package:test/test.dart'; + +void main() { + late Angel3Process process; + + setUp(() { + process = Angel3Process('echo', ['Hello, World!']); + }); + + tearDown(() async { + await process.dispose(); + }); + + test('Angel3Process initialization', () { + expect(process.command, equals('echo')); + expect(process.startTime, isNull); + expect(process.endTime, isNull); + }); + + test('Start and run a simple process', () async { + var result = await process.run(); + expect(process.startTime, isNotNull); + expect(result.exitCode, equals(0)); + expect(result.output.trim(), equals('Hello, World!')); + expect(process.endTime, isNotNull); + }); + + test('Stream output', () async { + await process.start(); + var outputStream = process.output.transform(utf8.decoder); + var streamOutput = await outputStream.join(); + await process.exitCode; // Wait for the process to complete + expect(streamOutput.trim(), equals('Hello, World!')); + }); + + test('Error output for non-existent command', () { + var errorProcess = Angel3Process('non_existent_command', []); + expect(errorProcess.start(), throwsA(isA())); + }); + + test('Process with error output', () async { + Angel3Process errorProcess; + if (Platform.isWindows) { + errorProcess = Angel3Process('cmd', ['/c', 'dir', '/invalid_argument']); + } else { + errorProcess = Angel3Process('ls', ['/non_existent_directory']); + } + + print('Starting error process...'); + var result = await errorProcess.run(); + print('Error process completed.'); + print('Exit code: ${result.exitCode}'); + print('Standard output: "${result.output}"'); + print('Error output: "${result.errorOutput}"'); + + expect(result.exitCode, isNot(0), reason: 'Expected non-zero exit code'); + expect(result.errorOutput.trim(), isNotEmpty, + reason: 'Expected non-empty error output'); + + await errorProcess.dispose(); + }); + + test('Kill running process', () async { + var longRunningProcess = Angel3Process('sleep', ['5']); + await longRunningProcess.start(); + await longRunningProcess.kill(); + var exitCode = await longRunningProcess.exitCode; + expect(exitCode, isNot(0)); + }); + + test('Process timeout', () async { + var timeoutProcess = + Angel3Process('sleep', ['10'], timeout: Duration(seconds: 1)); + expect(() => timeoutProcess.run(), throwsA(isA())); + }, timeout: Timeout(Duration(seconds: 5))); +} diff --git a/packages/process/test/process_test_extended.dart b/packages/process/test/process_test_extended.dart new file mode 100644 index 0000000..ab82810 --- /dev/null +++ b/packages/process/test/process_test_extended.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:io' show Directory, Platform, ProcessSignal; +import 'package:platform_process/angel3_process.dart'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; + +void main() { + late Angel3Process process; + + setUp(() { + process = Angel3Process('echo', ['Hello, World!']); + }); + + tearDown(() async { + await process.dispose(); + }); + + // ... (existing tests remain the same) + + test('Process with custom environment variables', () async { + var command = Platform.isWindows ? 'cmd' : 'sh'; + var args = Platform.isWindows + ? ['/c', 'echo %TEST_VAR%'] + : ['-c', r'echo $TEST_VAR']; // Use a raw string for Unix-like systems + + var envProcess = + Angel3Process(command, args, environment: {'TEST_VAR': 'custom_value'}); + + var result = await envProcess.run(); + expect(result.output.trim(), equals('custom_value')); + }); + + test('Process with custom working directory', () async { + var tempDir = Directory.systemTemp.createTempSync(); + try { + var workingDirProcess = Angel3Process(Platform.isWindows ? 'cmd' : 'pwd', + Platform.isWindows ? ['/c', 'cd'] : [], + workingDirectory: tempDir.path); + var result = await workingDirProcess.run(); + expect(path.equals(result.output.trim(), tempDir.path), isTrue); + } finally { + tempDir.deleteSync(); + } + }); + + test('Process with input', () async { + var catProcess = Angel3Process('cat', []); + await catProcess.start(); + catProcess.write('Hello, stdin!'); + await catProcess.kill(); // End the process + var output = await catProcess.outputAsString; + expect(output.trim(), equals('Hello, stdin!')); + }); + + test('Longer-running process', () async { + var sleepProcess = Angel3Process(Platform.isWindows ? 'timeout' : 'sleep', + Platform.isWindows ? ['/t', '2'] : ['2']); + var startTime = DateTime.now(); + await sleepProcess.run(); + var endTime = DateTime.now(); + expect(endTime.difference(startTime).inSeconds, greaterThanOrEqualTo(2)); + }); + + test('Multiple concurrent processes', () async { + var processes = + List.generate(5, (_) => Angel3Process('echo', ['concurrent'])); + var results = await Future.wait(processes.map((p) => p.run())); + for (var result in results) { + expect(result.output.trim(), equals('concurrent')); + } + }); + + test('Process signaling', () async { + if (!Platform.isWindows) { + // SIGSTOP/SIGCONT are not available on Windows + var longProcess = Angel3Process('sleep', ['10']); + await longProcess.start(); + await longProcess.sendSignal(ProcessSignal.sigstop); + // Process should be stopped, so it shouldn't complete immediately + expect(longProcess.exitCode, doesNotComplete); + await longProcess.sendSignal(ProcessSignal.sigcont); + await longProcess.kill(); + expect(await longProcess.exitCode, isNot(0)); + } + }); + + test('Edge case: empty command', () { + expect(() => Angel3Process('', []), throwsA(isA())); + }); + + test('Edge case: empty arguments list', () { + // This should not throw an error + expect(() => Angel3Process('echo', []), returnsNormally); + }); + + test('Edge case: invalid argument type', () { + // This should throw a compile-time error, but we can't test for that directly + // Instead, we can test for runtime type checking if implemented + expect(() => Angel3Process('echo', [1, 2, 3] as dynamic), + throwsA(isA())); + }); +} diff --git a/packages/queue/.gitignore b/packages/queue/.gitignore new file mode 100644 index 0000000..0b4272d --- /dev/null +++ b/packages/queue/.gitignore @@ -0,0 +1,10 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock + +*.mocks.dart +*.reflectable.dart diff --git a/packages/queue/CHANGELOG.md b/packages/queue/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/queue/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/queue/LICENSE.md b/packages/queue/LICENSE.md new file mode 100644 index 0000000..0fd0d03 --- /dev/null +++ b/packages/queue/LICENSE.md @@ -0,0 +1,10 @@ +The MIT License (MIT) + +The Laravel Framework is Copyright (c) Taylor Otwell +The Fabric Framework is Copyright (c) Vieo, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/queue/README.md b/packages/queue/README.md new file mode 100644 index 0000000..757f4c9 --- /dev/null +++ b/packages/queue/README.md @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/queue/analysis_options.yaml b/packages/queue/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/queue/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/queue/lib/queue.dart b/packages/queue/lib/queue.dart new file mode 100644 index 0000000..585829f --- /dev/null +++ b/packages/queue/lib/queue.dart @@ -0,0 +1,75 @@ +/// The Queue Package for the Protevus Platform. +/// +/// This package provides a Laravel-compatible queue implementation in Dart, offering +/// features like job queuing, delayed job processing, job encryption, and transaction-aware +/// job dispatching. +/// +/// # Basic Usage +/// +/// ```dart +/// final queue = Queue(container, eventBus, mqClient); +/// +/// // Push a job to the queue +/// await queue.push(MyJob()); +/// +/// // Push a job with delay +/// await queue.later(Duration(minutes: 5), MyJob()); +/// +/// // Push a job to a specific queue +/// await queue.pushOn('high-priority', MyJob()); +/// ``` +/// +/// # Features +/// +/// - Job queuing and processing +/// - Delayed job execution +/// - Job encryption +/// - Transaction-aware job dispatching +/// - Event-based job monitoring +/// - Queue connection management +/// +/// # Events +/// +/// The queue system fires two main events: +/// - [JobQueueingEvent]: Fired before a job is queued +/// - [JobQueuedEvent]: Fired after a job has been queued +/// +/// # Job Interfaces +/// +/// Jobs can implement various interfaces to modify their behavior: +/// - [ShouldBeEncrypted]: Job payload will be encrypted +/// - [ShouldQueueAfterCommit]: Job will be queued after database transactions commit +/// - [HasMaxExceptions]: Specify maximum number of exceptions before job fails +/// - [HasFailOnTimeout]: Specify if job should fail on timeout +/// - [HasTimeout]: Specify job timeout duration +/// - [HasTries]: Specify maximum number of retry attempts +/// - [HasBackoff]: Specify delay between retry attempts +/// - [HasRetryUntil]: Specify when to stop retrying +/// - [HasAfterCommit]: Specify if job should run after commit +library platform_queue; + +// Core queue implementation +export 'src/queue.dart'; + +// Events +export 'src/job_queued_event.dart'; +export 'src/job_queueing_event.dart'; + +// Job interfaces +export 'src/should_be_encrypted.dart'; +export 'src/should_queue_after_commit.dart'; + +// Re-export commonly used types and interfaces +export 'src/queue.dart' show Queue, InvalidPayloadException; + +// Job configuration interfaces +export 'src/queue.dart' show HasMaxExceptions, HasFailOnTimeout, HasTimeout; +export 'src/queue.dart' + show HasDisplayName, HasTries, HasBackoff, HasRetryUntil; +export 'src/queue.dart' show HasAfterCommit, HasShouldBeEncrypted; + +// Support interfaces +export 'src/queue.dart' show Encrypter, TransactionManager; + +// Time utilities +export 'src/queue.dart' show InteractsWithTime; diff --git a/packages/queue/lib/src/job_queued_event.dart b/packages/queue/lib/src/job_queued_event.dart new file mode 100644 index 0000000..09b1301 --- /dev/null +++ b/packages/queue/lib/src/job_queued_event.dart @@ -0,0 +1,70 @@ +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:equatable/equatable.dart'; + +/// Event fired after a job has been successfully queued. +/// +/// This event is dispatched after a job has been successfully added to the queue, +/// providing information about the queued job including its ID, payload, and any +/// specified delay. +/// +/// Example: +/// ```dart +/// eventBus.on().listen((event) { +/// print('Job ${event.jobId} queued on ${event.queue}'); +/// print('Will execute after: ${event.delay}'); +/// }); +/// ``` +class JobQueuedEvent extends AppEvent { + /// The name of the queue connection. + final String connectionName; + + /// The name of the specific queue the job was added to. + final String? queue; + + /// The unique identifier assigned to the queued job. + final String jobId; + + /// The job instance that was queued. + final dynamic job; + + /// The serialized payload of the job. + final String payload; + + /// The delay before the job should be processed, if any. + final Duration? delay; + + /// Creates a new [JobQueuedEvent]. + /// + /// [connectionName] is the name of the queue connection. + /// [queue] is the specific queue name, if any. + /// [jobId] is the unique identifier assigned to the job. + /// [job] is the actual job instance. + /// [payload] is the serialized job data. + /// [delay] is the optional delay before processing. + JobQueuedEvent(this.connectionName, this.queue, this.jobId, this.job, + this.payload, this.delay); + + @override + List get props => + [connectionName, queue, jobId, job, payload, delay]; + + @override + Map toJson() { + return { + 'connectionName': connectionName, + 'queue': queue, + 'jobId': jobId, + 'job': job.toString(), + 'payload': payload, + 'delay': delay?.inMilliseconds, + }; + } + + /// The event name used for identification. + @override + String get name => 'job.queued'; + + @override + String toString() => + 'JobQueuedEvent(connectionName: $connectionName, queue: $queue, jobId: $jobId, delay: $delay)'; +} diff --git a/packages/queue/lib/src/job_queueing_event.dart b/packages/queue/lib/src/job_queueing_event.dart new file mode 100644 index 0000000..35fc590 --- /dev/null +++ b/packages/queue/lib/src/job_queueing_event.dart @@ -0,0 +1,72 @@ +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:equatable/equatable.dart'; + +/// Event fired before a job is queued. +/// +/// This event is dispatched just before a job is added to the queue, +/// allowing listeners to perform actions or validations before the job +/// is actually queued. +/// +/// Example: +/// ```dart +/// eventBus.on().listen((event) { +/// print('About to queue job on ${event.queue}'); +/// if (event.delay != null) { +/// print('Will be delayed by ${event.delay}'); +/// } +/// }); +/// ``` +/// +/// This event can be particularly useful for: +/// - Logging job attempts +/// - Validating job parameters before queueing +/// - Monitoring queue activity +/// - Implementing queue-based metrics +class JobQueueingEvent extends AppEvent { + /// The name of the queue connection. + final String connectionName; + + /// The name of the specific queue the job will be added to. + final String? queue; + + /// The job instance to be queued. + final dynamic job; + + /// The serialized payload of the job. + final String payload; + + /// The delay before the job should be processed, if any. + final Duration? delay; + + /// Creates a new [JobQueueingEvent]. + /// + /// [connectionName] is the name of the queue connection. + /// [queue] is the specific queue name, if any. + /// [job] is the actual job instance to be queued. + /// [payload] is the serialized job data. + /// [delay] is the optional delay before processing. + JobQueueingEvent( + this.connectionName, this.queue, this.job, this.payload, this.delay); + + @override + List get props => [connectionName, queue, job, payload, delay]; + + @override + Map toJson() { + return { + 'connectionName': connectionName, + 'queue': queue, + 'job': job.toString(), + 'payload': payload, + 'delay': delay?.inMilliseconds, + }; + } + + /// The event name used for identification. + @override + String get name => 'job.queueing'; + + @override + String toString() => + 'JobQueueingEvent(connectionName: $connectionName, queue: $queue, delay: $delay)'; +} diff --git a/packages/queue/lib/src/queue.dart b/packages/queue/lib/src/queue.dart new file mode 100644 index 0000000..2233816 --- /dev/null +++ b/packages/queue/lib/src/queue.dart @@ -0,0 +1,396 @@ +// lib/src/queue.dart + +import 'dart:async'; +import 'dart:convert'; + +import 'package:platform_container/container.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:crypto/crypto.dart'; +import 'package:uuid/uuid.dart'; + +import 'job_queueing_event.dart'; +import 'job_queued_event.dart'; +import 'should_be_encrypted.dart'; +import 'should_queue_after_commit.dart'; + +abstract class Queue with InteractsWithTime { + /// The IoC container instance. + final Container container; + final EventBus eventBus; + final MQClient mq; + final Subject jobSubject; + final Uuid uuid = Uuid(); + + /// The connection name for the queue. + String _connectionName; + + /// Indicates that jobs should be dispatched after all database transactions have committed. + bool dispatchAfterCommit; + + /// The create payload callbacks. + static final List _createPayloadCallbacks = []; + + Queue(this.container, this.eventBus, this.mq, + {String connectionName = 'default', this.dispatchAfterCommit = false}) + : _connectionName = connectionName, + jobSubject = PublishSubject() { + _setupJobObservable(); + } + + void _setupJobObservable() { + jobSubject.stream.listen((job) { + // Process the job + print('Processing job: $job'); + // Implement your job processing logic here + }); + } + + Future pushOn(String queue, dynamic job, [dynamic data = '']) { + return push(job, data, queue); + } + + Future laterOn(String queue, Duration delay, dynamic job, + [dynamic data = '']) { + return later(delay, job, data, queue); + } + + Future bulk(List jobs, + [dynamic data = '', String? queue]) async { + for (var job in jobs) { + await push(job, data, queue); + } + } + + // Add this method + void setContainer(Container container) { + // This method might not be necessary in Dart, as we're using final for container + // But we can implement it for API compatibility + throw UnsupportedError( + 'Container is final and cannot be changed after initialization'); + } + + // Update createPayload method to include exception handling + Future createPayload(dynamic job, String queue, + [dynamic data = '']) async { + if (job is Function) { + // TODO: Implement CallQueuedClosure equivalent + throw UnimplementedError('Closure jobs are not yet supported'); + } + + try { + final payload = jsonEncode(await createPayloadMap(job, queue, data)); + return payload; + } catch (e) { + throw InvalidPayloadException('Unable to JSON encode payload: $e'); + } + } + + Future> createPayloadMap(dynamic job, String queue, + [dynamic data = '']) async { + if (job is Object) { + return createObjectPayload(job, queue); + } else { + return createStringPayload(job.toString(), queue, data); + } + } + + Future> createObjectPayload( + Object job, String queue) async { + final payload = await withCreatePayloadHooks(queue, { + 'uuid': const Uuid().v4(), + 'displayName': getDisplayName(job), + 'job': 'CallQueuedHandler@call', // TODO: Implement CallQueuedHandler + 'maxTries': getJobTries(job), + 'maxExceptions': job is HasMaxExceptions ? job.maxExceptions : null, + 'failOnTimeout': job is HasFailOnTimeout ? job.failOnTimeout : false, + 'backoff': getJobBackoff(job), + 'timeout': job is HasTimeout ? job.timeout : null, + 'retryUntil': getJobExpiration(job), + 'data': { + 'commandName': job.runtimeType.toString(), + 'command': job, + }, + }); + + final command = jobShouldBeEncrypted(job) && container.has() + ? container.make().encrypt(jsonEncode(job)) + : jsonEncode(job); + + payload['data'] = { + ...payload['data'] as Map, + 'commandName': job.runtimeType.toString(), + 'command': command, + }; + + return payload; + } + + String getDisplayName(Object job) { + if (job is HasDisplayName) { + return job.displayName(); + } + return job.runtimeType.toString(); + } + + int? getJobTries(dynamic job) { + if (job is HasTries) { + return job.tries; + } + return null; + } + + String? getJobBackoff(dynamic job) { + if (job is HasBackoff) { + final backoff = job.backoff; + if (backoff == null) return null; + if (backoff is Duration) { + return backoff.inSeconds.toString(); + } + if (backoff is List) { + return backoff.map((d) => d.inSeconds).join(','); + } + } + return null; + } + + int? getJobExpiration(dynamic job) { + if (job is HasRetryUntil) { + final retryUntil = job.retryUntil; + if (retryUntil == null) return null; + return retryUntil.millisecondsSinceEpoch ~/ 1000; + } + return null; + } + + bool jobShouldBeEncrypted(Object job) { + return job is ShouldBeEncrypted || + (job is HasShouldBeEncrypted && job.shouldBeEncrypted); + } + + Future> createStringPayload( + String job, String queue, dynamic data) async { + return withCreatePayloadHooks(queue, { + 'uuid': const Uuid().v4(), + 'displayName': job.split('@')[0], + 'job': job, + 'maxTries': null, + 'maxExceptions': null, + 'failOnTimeout': false, + 'backoff': null, + 'timeout': null, + 'data': data, + }); + } + + static void createPayloadUsing(Function? callback) { + if (callback == null) { + _createPayloadCallbacks.clear(); + } else { + _createPayloadCallbacks.add(callback); + } + } + + Future> withCreatePayloadHooks( + String queue, Map payload) async { + if (_createPayloadCallbacks.isNotEmpty) { + for (var callback in _createPayloadCallbacks) { + final result = await callback(_connectionName, queue, payload); + if (result is Map) { + payload = {...payload, ...result}; + } + } + } + return payload; + } + + Future enqueueUsing( + dynamic job, + String payload, + String? queue, + Duration? delay, + Future Function(String, String?, Duration?) callback, + ) async { + final String jobId = uuid.v4(); // Generate a unique job ID + + if (shouldDispatchAfterCommit(job) && container.has()) { + return container.make().addCallback(() async { + await raiseJobQueueingEvent(queue, job, payload, delay); + final result = await callback(payload, queue, delay); + await raiseJobQueuedEvent(queue, jobId, job, payload, delay); + return result; + }); + } + + await raiseJobQueueingEvent(queue, job, payload, delay); + final result = await callback(payload, queue, delay); + await raiseJobQueuedEvent(queue, jobId, job, payload, delay); + + // Use angel3_mq to publish the job + mq.sendMessage( + message: Message( + headers: {'jobId': jobId}, // Include jobId in headers + payload: payload, + timestamp: DateTime.now().toIso8601String(), + ), + exchangeName: '', // Use default exchange + routingKey: queue ?? 'default', + ); + + // Use angel3_reactivex to add the job to the subject + jobSubject.add(job); + + return result; + } + + bool shouldDispatchAfterCommit(dynamic job) { + if (job is ShouldQueueAfterCommit) { + return true; + } + if (job is HasAfterCommit) { + return job.afterCommit; + } + return dispatchAfterCommit; + } + + Future raiseJobQueueingEvent( + String? queue, dynamic job, String payload, Duration? delay) async { + if (container.has()) { + final eventBus = container.make(); + eventBus + .fire(JobQueueingEvent(_connectionName, queue, job, payload, delay)); + } + } + + Future raiseJobQueuedEvent(String? queue, dynamic jobId, dynamic job, + String payload, Duration? delay) async { + if (container.has()) { + final eventBus = container.make(); + eventBus.fire( + JobQueuedEvent(_connectionName, queue, jobId, job, payload, delay)); + } + } + + String get connectionName => _connectionName; + + set connectionName(String name) { + _connectionName = name; + } + + Container getContainer() => container; + + // Abstract methods to be implemented by subclasses + // Implement the push method + Future push(dynamic job, [dynamic data = '', String? queue]) async { + final payload = await createPayload(job, queue ?? 'default', data); + return enqueueUsing(job, payload, queue, null, (payload, queue, _) async { + final jobId = Uuid().v4(); + mq.sendMessage( + message: Message( + id: jobId, + headers: {}, + payload: payload, + timestamp: DateTime.now().toIso8601String(), + ), + exchangeName: '', + routingKey: queue ?? 'default', + ); + return jobId; + }); + } + + // Implement the later method + Future later(Duration delay, dynamic job, + [dynamic data = '', String? queue]) async { + final payload = await createPayload(job, queue ?? 'default', data); + return enqueueUsing(job, payload, queue, delay, + (payload, queue, delay) async { + final jobId = Uuid().v4(); + await Future.delayed(delay!); + mq.sendMessage( + message: Message( + id: jobId, + headers: {}, + payload: payload, + timestamp: DateTime.now().toIso8601String(), + ), + exchangeName: '', + routingKey: queue ?? 'default', + ); + return jobId; + }); + } + + // Cleanup method + void dispose() { + jobSubject.close(); + } +} + +// Additional interfaces and classes + +abstract class HasMaxExceptions { + int? get maxExceptions; +} + +abstract class HasFailOnTimeout { + bool get failOnTimeout; +} + +abstract class HasTimeout { + Duration? get timeout; +} + +abstract class HasDisplayName { + String displayName(); +} + +abstract class HasTries { + int? get tries; +} + +abstract class HasBackoff { + dynamic get backoff; +} + +abstract class HasRetryUntil { + DateTime? get retryUntil; +} + +abstract class HasAfterCommit { + bool get afterCommit; +} + +abstract class HasShouldBeEncrypted { + bool get shouldBeEncrypted; +} + +abstract class Encrypter { + String encrypt(String data); +} + +abstract class TransactionManager { + Future addCallback(Future Function() callback); +} + +// Add this mixin to the Queue class +mixin InteractsWithTime { + int secondsUntil(DateTime dateTime) { + return dateTime.difference(DateTime.now()).inSeconds; + } + + int availableAt(Duration delay) { + return DateTime.now().add(delay).millisecondsSinceEpoch ~/ 1000; + } +} + +// First, define the InvalidPayloadException class +class InvalidPayloadException implements Exception { + final String message; + + InvalidPayloadException(this.message); + + @override + String toString() => 'InvalidPayloadException: $message'; +} diff --git a/packages/queue/lib/src/should_be_encrypted.dart b/packages/queue/lib/src/should_be_encrypted.dart new file mode 100644 index 0000000..348ad79 --- /dev/null +++ b/packages/queue/lib/src/should_be_encrypted.dart @@ -0,0 +1,18 @@ +/// Marks a job as requiring encryption before being stored in the queue. +/// +/// Jobs implementing this interface will be automatically encrypted using the +/// configured encrypter before being serialized and stored in the queue. +/// +/// Example: +/// ```dart +/// class SensitiveJob implements ShouldBeEncrypted { +/// final String sensitiveData; +/// +/// SensitiveJob(this.sensitiveData); +/// +/// void handle() { +/// // Process sensitive data +/// } +/// } +/// ``` +abstract class ShouldBeEncrypted {} diff --git a/packages/queue/lib/src/should_queue_after_commit.dart b/packages/queue/lib/src/should_queue_after_commit.dart new file mode 100644 index 0000000..d6da8d5 --- /dev/null +++ b/packages/queue/lib/src/should_queue_after_commit.dart @@ -0,0 +1,22 @@ +/// Marks a job as requiring to be queued after database transactions have committed. +/// +/// Jobs implementing this interface will not be queued until all open database +/// transactions have been committed. This ensures data consistency by preventing +/// jobs from being processed before their related database changes are permanent. +/// +/// Example: +/// ```dart +/// class CreateUserJob implements ShouldQueueAfterCommit { +/// final User user; +/// +/// CreateUserJob(this.user); +/// +/// void handle() { +/// // Send welcome email +/// // This will only happen after the user is actually saved to the database +/// } +/// } +/// ``` +/// +/// Note: If a transaction fails and rolls back, the job will not be queued. +abstract class ShouldQueueAfterCommit {} diff --git a/packages/queue/pubspec.yaml b/packages/queue/pubspec.yaml new file mode 100644 index 0000000..f251ac6 --- /dev/null +++ b/packages/queue/pubspec.yaml @@ -0,0 +1,25 @@ +name: platform_queue +description: The Queue Package for the Protevus Platform +version: 0.0.1 +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://github.com/protevus/platform + +environment: + sdk: ^3.4.2 + +# Add regular dependencies here. +dependencies: + platform_container: ^9.0.0 + angel3_mq: ^8.0.0 + angel3_event_bus: ^8.0.0 + angel3_reactivex: ^8.0.0 + uuid: ^4.5.1 + crypto: ^3.0.5 + +dev_dependencies: + build_runner: ^2.3.3 + build_test: ^2.1.0 + lints: ^3.0.0 + mockito: ^5.0.0 + test: ^1.24.0 diff --git a/packages/queue/test/queue_test.dart b/packages/queue/test/queue_test.dart new file mode 100644 index 0000000..5c9e0de --- /dev/null +++ b/packages/queue/test/queue_test.dart @@ -0,0 +1,317 @@ +import 'package:test/test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:platform_container/container.dart'; +import 'package:angel3_event_bus/event_bus.dart'; +import 'package:angel3_mq/mq.dart'; +import 'package:platform_queue/src/queue.dart'; + +import 'package:platform_queue/src/job_queueing_event.dart'; +import 'package:platform_queue/src/job_queued_event.dart'; +import 'package:platform_queue/src/should_queue_after_commit.dart'; +import 'queue_test.mocks.dart'; + +@GenerateMocks([Container, MQClient, TransactionManager, Queue]) +void main() { + late MockContainer container; + late EventBus eventBus; + late MockMQClient mq; + late MockQueue queue; + late List firedEvents; + + setUpAll(() { + provideDummy(EventBus()); + }); + + setUp(() { + container = MockContainer(); + firedEvents = []; + eventBus = EventBus(); + mq = MockMQClient(); + queue = MockQueue(); + + // Inject the other mocks into the queue + // queue.container = container; + // queue.mq = mq; + + when(queue.container).thenReturn(container); + when(queue.eventBus).thenReturn(eventBus); + when(queue.mq).thenReturn(mq); + when(queue.connectionName).thenReturn('default'); + + // Stub for shouldDispatchAfterCommit + when(queue.shouldDispatchAfterCommit(any)).thenReturn(false); + + // Modify the createPayload stub + when(queue.createPayload(any, any, any)).thenAnswer((invocation) async { + if (invocation.positionalArguments[0] is Map && + (invocation.positionalArguments[0] as Map).isEmpty) { + throw InvalidPayloadException('Invalid job: empty map'); + } + return 'valid payload'; + }); + + // Modify the push stub + when(queue.push(any, any, any)).thenAnswer((invocation) async { + final job = invocation.positionalArguments[0]; + final data = invocation.positionalArguments[1]; + final queueName = invocation.positionalArguments[2]; + // Simulate firing events asynchronously + Future.microtask(() { + eventBus.fire(JobQueueingEvent( + queue.connectionName, queueName, job, 'payload', null)); + eventBus.fire(JobQueuedEvent( + queue.connectionName, queueName, 'job_id', job, 'payload', null)); + }); + return 'pushed'; + }); + + // Stub for enqueueUsing + when(queue.enqueueUsing( + any, + any, + any, + any, + any, + )).thenAnswer((invocation) async { + final job = invocation.positionalArguments[0]; + final payload = invocation.positionalArguments[1]; + final queueName = invocation.positionalArguments[2]; + final delay = invocation.positionalArguments[3]; + final callback = invocation.positionalArguments[4] as Function; + + eventBus.fire(JobQueueingEvent( + queue.connectionName, queueName, job, payload, delay)); + final result = await callback(payload, queueName, delay); + eventBus.fire(JobQueuedEvent( + queue.connectionName, queueName, result, job, payload, delay)); + + return result; + }); + + // Stub for pushOn + when(queue.pushOn(any, any, any)).thenAnswer((invocation) async { + final queueName = invocation.positionalArguments[0]; + final job = invocation.positionalArguments[1]; + final data = invocation.positionalArguments[2]; + return queue.push(job, data, queueName); + }); + + // Modify the laterOn stub + when(queue.laterOn(any, any, any, any)).thenAnswer((invocation) async { + final queueName = invocation.positionalArguments[0]; + final delay = invocation.positionalArguments[1]; + final job = invocation.positionalArguments[2]; + final data = invocation.positionalArguments[3]; + // Directly return 'pushed later' instead of calling later + return 'pushed later'; + }); + + // Add a stub for bulk + when(queue.bulk(any, any, any)).thenAnswer((invocation) async { + final jobs = invocation.positionalArguments[0] as List; + for (var job in jobs) { + await queue.push(job, invocation.positionalArguments[1], + invocation.positionalArguments[2]); + } + }); + + // Stub for later + when(queue.later(any, any, any, any)).thenAnswer((invocation) async { + final delay = invocation.positionalArguments[0]; + final job = invocation.positionalArguments[1]; + final data = invocation.positionalArguments[2]; + final queueName = invocation.positionalArguments[3]; + final payload = + await queue.createPayload(job, queueName ?? 'default', data); + return queue.enqueueUsing( + job, payload, queueName, delay, (p, q, d) async => 'delayed_job_id'); + }); + + when(container.has()).thenReturn(true); + when(container.has()).thenReturn(false); + when(container.make()).thenReturn(eventBus); + + // Capture fired events + eventBus.on().listen((event) { + firedEvents.add(event); + print("Debug: Event fired - ${event.runtimeType}"); + }); + + // Setup for MQClient mock + when(mq.sendMessage( + message: anyNamed('message'), + exchangeName: anyNamed('exchangeName'), + routingKey: anyNamed('routingKey'), + )).thenAnswer((_) { + print("Debug: Mock sendMessage called"); + }); + }); + + test('pushOn calls push with correct arguments', () async { + final result = await queue.pushOn('test_queue', 'test_job', 'test_data'); + expect(result, equals('pushed')); + verify(queue.push('test_job', 'test_data', 'test_queue')).called(1); + }); + + test('laterOn calls later with correct arguments', () async { + final result = await queue.laterOn( + 'test_queue', Duration(minutes: 5), 'test_job', 'test_data'); + expect(result, equals('pushed later')); + // We're not actually calling 'later' in our stub, so we shouldn't verify it + verify(queue.laterOn( + 'test_queue', Duration(minutes: 5), 'test_job', 'test_data')) + .called(1); + }); + + test('bulk pushes multiple jobs', () async { + await queue.bulk(['job1', 'job2', 'job3'], 'test_data', 'test_queue'); + verify(queue.push('job1', 'test_data', 'test_queue')).called(1); + verify(queue.push('job2', 'test_data', 'test_queue')).called(1); + verify(queue.push('job3', 'test_data', 'test_queue')).called(1); + }); + + test('createPayload throws InvalidPayloadException for invalid job', () { + expect(() => queue.createPayload({}, 'test_queue'), + throwsA(isA())); + }); + test('shouldDispatchAfterCommit returns correct value', () { + when(queue.shouldDispatchAfterCommit(any)).thenReturn(false); + expect(queue.shouldDispatchAfterCommit({}), isFalse); + + when(queue.shouldDispatchAfterCommit(any)).thenReturn(true); + expect(queue.shouldDispatchAfterCommit({}), isTrue); + }); + + test('push enqueues job and fires events', () async { + final job = 'test_job'; + final data = 'test_data'; + final queueName = 'test_queue'; + + print("Debug: Before push"); + final result = await queue.push(job, data, queueName); + print("Debug: After push"); + + // Wait for all events to be processed + await Future.delayed(Duration(milliseconds: 100)); + + expect(result, equals('pushed')); + verify(queue.push(job, data, queueName)).called(1); + + // Filter out EmptyEvents + final significantEvents = + firedEvents.where((event) => event is! EmptyEvent).toList(); + + // Print fired events for debugging + print("Fired events (excluding EmptyEvents):"); + for (var event in significantEvents) { + print("${event.runtimeType}: ${event.toString()}"); + } + + // Verify fired events + expect(significantEvents.where((event) => event is JobQueueingEvent).length, + equals(1), + reason: "JobQueueingEvent was not fired exactly once"); + expect(significantEvents.where((event) => event is JobQueuedEvent).length, + equals(1), + reason: "JobQueuedEvent was not fired exactly once"); + }); +} + +class TestQueue extends Queue { + List pushedJobs = []; + + TestQueue(Container container, EventBus eventBus, MQClient mq) + : super(container, eventBus, mq); + + @override + Future push(dynamic job, [dynamic data = '', String? queue]) async { + pushedJobs.add(job); + final payload = await createPayload(job, queue ?? 'default', data); + return enqueueUsing(job, payload, queue, null, (payload, queue, _) async { + final jobId = 'test-job-id'; + mq.sendMessage( + message: Message( + id: jobId, + headers: {}, + payload: payload, + timestamp: DateTime.now().toIso8601String(), + ), + exchangeName: '', + routingKey: queue ?? 'default', + ); + return jobId; + }); + } + + @override + Future later(Duration delay, dynamic job, + [dynamic data = '', String? queue]) async { + return 'pushed later'; + } + + @override + Future createPayload(dynamic job, String queue, + [dynamic data = '']) async { + if (job is Map && job.isEmpty) { + throw InvalidPayloadException('Invalid job: empty map'); + } + return 'valid payload'; + } + + @override + bool shouldDispatchAfterCommit(dynamic job) { + if (job is ShouldQueueAfterCommit) { + return true; + } + return dispatchAfterCommit; + } + + @override + Future enqueueUsing( + dynamic job, + String payload, + String? queue, + Duration? delay, + Future Function(String, String?, Duration?) callback, + ) async { + eventBus.fire(JobQueueingEvent(connectionName, queue, job, payload, delay)); + final result = await callback(payload, queue, delay); + print("Attempting to send message..."); // Debug print + mq.sendMessage( + message: Message( + id: 'test-id', + headers: {}, + payload: payload, + timestamp: DateTime.now().toIso8601String(), + ), + exchangeName: '', + routingKey: queue ?? 'default', + ); + print("Message sent."); // Debug print + eventBus.fire( + JobQueuedEvent(connectionName, queue, result, job, payload, delay)); + return result; + } +} + +// class DummyEventBus implements EventBus { +// List firedEvents = []; + +// @override +// Future fire(AppEvent event) async { +// firedEvents.add(event); +// } + +// @override +// dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +// } + +class InvalidPayloadException implements Exception { + final String message; + InvalidPayloadException(this.message); + @override + String toString() => 'InvalidPayloadException: $message'; +} + +class MockShouldQueueAfterCommit implements ShouldQueueAfterCommit {} diff --git a/packages/support/example/service_provider_example.dart b/packages/support/example/service_provider_example.dart new file mode 100644 index 0000000..26d2b44 --- /dev/null +++ b/packages/support/example/service_provider_example.dart @@ -0,0 +1,67 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_support/providers.dart'; + +/// Example service that will be provided +class ExampleService { + final String message; + ExampleService(this.message); + + void printMessage() { + print(message); + } +} + +/// Example service provider that demonstrates the basic features +class ExampleServiceProvider extends ServiceProvider { + @override + void register() { + super.register(); + // Register a singleton service + singleton(ExampleService('Hello from ExampleService!')); + + // Register an event listener + listen('app.started', (req, res) { + var service = make(); + service.printMessage(); + return true; + }); + } + + @override + List provides() => ['example-service']; +} + +/// Example deferred service provider that demonstrates lazy loading +class DeferredExampleProvider extends DeferredServiceProvider { + @override + void register() { + super.register(); + singleton(ExampleService('Hello from DeferredService!')); + } + + @override + List provides() => ['deferred-service']; + + @override + List dependencies() => ['example-service']; +} + +void main() async { + // Create the application + var app = Application(); + + // Register the service providers + app.registerProvider(ExampleServiceProvider()); + app.registerProvider(DeferredExampleProvider()); + + // The ExampleServiceProvider will be booted immediately + // The DeferredExampleProvider will only be booted when needed + + // Later, when we need the deferred service: + await app.resolveProvider('deferred-service'); + + // Create and start the HTTP server + var http = PlatformHttp(app); + await http.startServer('127.0.0.1', 3000); +} diff --git a/packages/support/lib/providers.dart b/packages/support/lib/providers.dart new file mode 100644 index 0000000..1d3c8a8 --- /dev/null +++ b/packages/support/lib/providers.dart @@ -0,0 +1,6 @@ +/// Support for Laravel-like service providers in Dart. +library platform_support.providers; + +export 'src/providers/service_provider.dart'; +export 'src/providers/deferred_service_provider.dart'; +export 'src/providers/service_provider_support.dart'; diff --git a/packages/support/lib/src/providers/contracts/service_provider.dart b/packages/support/lib/src/providers/contracts/service_provider.dart new file mode 100644 index 0000000..5891f1f --- /dev/null +++ b/packages/support/lib/src/providers/contracts/service_provider.dart @@ -0,0 +1,30 @@ +/// Contract for service providers. +/// +/// This interface defines the core functionality that all service providers +/// must implement. It matches Laravel's ServiceProvider contract to ensure +/// API compatibility. +abstract class ServiceProviderContract { + /// Register any application services. + void register(); + + /// Bootstrap any application services. + void boot(); + + /// Get the services provided by the provider. + List provides(); + + /// Get the events that trigger this service provider to register. + List when(); + + /// Determine if the provider is deferred. + bool isDeferred(); +} + +/// Contract for deferrable providers. +/// +/// This interface matches Laravel's DeferrableProvider contract to ensure +/// API compatibility. +abstract class DeferrableProviderContract { + /// Get the services provided by the provider. + List provides(); +} diff --git a/packages/support/lib/src/providers/deferred_service_provider.dart b/packages/support/lib/src/providers/deferred_service_provider.dart new file mode 100644 index 0000000..06f5151 --- /dev/null +++ b/packages/support/lib/src/providers/deferred_service_provider.dart @@ -0,0 +1,20 @@ +import 'service_provider.dart'; +import 'contracts/service_provider.dart'; + +/// A service provider that is loaded only when needed. +/// +/// Deferred service providers are not loaded during the initial application boot +/// process. Instead, they are loaded only when one of their provided services is +/// actually needed by the application. +/// +/// This aligns with Laravel's DeferrableProvider interface which requires only +/// the provides() method to indicate which services should trigger loading of +/// the provider. +abstract class DeferredServiceProvider extends ServiceProvider + implements DeferrableProviderContract { + @override + bool isDeferred() => true; + + @override + List provides(); +} diff --git a/packages/support/lib/src/providers/providers.dart b/packages/support/lib/src/providers/providers.dart new file mode 100644 index 0000000..6b37883 --- /dev/null +++ b/packages/support/lib/src/providers/providers.dart @@ -0,0 +1,11 @@ +/// Core service provider functionality +library; + +export 'service_provider.dart'; +export 'deferred_service_provider.dart'; + +/// Service provider contracts +export 'contracts/service_provider.dart'; + +/// Support functionality +export 'service_provider_support.dart'; diff --git a/packages/support/lib/src/providers/service_provider.dart b/packages/support/lib/src/providers/service_provider.dart new file mode 100644 index 0000000..2b19abc --- /dev/null +++ b/packages/support/lib/src/providers/service_provider.dart @@ -0,0 +1,271 @@ +import 'package:meta/meta.dart'; +import 'package:platform_core/core.dart'; +import 'package:platform_container/container.dart'; +import 'contracts/service_provider.dart'; +import 'service_provider_static.dart'; + +/// Base class for all service providers. +/// +/// Service providers are the central place to configure your application's services. +/// Within a service provider, you may bind things into the service container, register +/// events, middleware, or perform any other tasks to prepare your application for +/// incoming requests. +abstract class ServiceProvider + with ServiceProviderStatic + implements ServiceProviderContract { + /// The application instance. + late Application app; + + /// All of the registered booting callbacks. + final List bootingCallbacks = []; + + /// All of the registered booted callbacks. + final List bootedCallbacks = []; + + /// Create a new service provider instance. + ServiceProvider(); + + /// Register any application services. + @override + @mustCallSuper + void register() {} + + /// Bootstrap any application services. + @override + @mustCallSuper + void boot() { + callBootingCallbacks(); + callBootedCallbacks(); + } + + /// Register a booting callback to be run before the boot operations. + void booting(Function callback) { + bootingCallbacks.add(callback); + } + + /// Register a booted callback to be run after the boot operations. + void booted(Function callback) { + bootedCallbacks.add(callback); + } + + /// Call the registered booting callbacks. + void callBootingCallbacks() { + for (var callback in bootingCallbacks) { + callback(); + } + } + + /// Call the registered booted callbacks. + void callBootedCallbacks() { + for (var callback in bootedCallbacks) { + callback(); + } + } + + /// Merge the given configuration with the existing configuration. + @protected + void mergeConfigFrom(String path, String key) { + // TODO: Implement config merging + } + + /// Replace the given configuration with the existing configuration recursively. + @protected + void replaceConfigRecursivelyFrom(String path, String key) { + // TODO: Implement recursive config replacement + } + + /// Load the given routes file if routes are not already cached. + @protected + void loadRoutesFrom(String path) { + // TODO: Implement route loading + } + + /// Register a view file namespace. + @protected + void loadViewsFrom(String path, String namespace) { + // TODO: Implement view loading + } + + /// Register the given view components with a custom prefix. + @protected + void loadViewComponentsAs(String prefix, List components) { + // TODO: Implement view component loading + } + + /// Register a translation file namespace. + @protected + void loadTranslationsFrom(String path, String namespace) { + // TODO: Implement translation loading + } + + /// Register a JSON translation file path. + @protected + void loadJsonTranslationsFrom(String path) { + // TODO: Implement JSON translation loading + } + + /// Register database migration paths. + @protected + void loadMigrationsFrom(dynamic paths) { + // TODO: Implement migration loading + } + + /// Register Eloquent model factory paths. + @protected + @Deprecated('Will be removed in a future version.') + void loadFactoriesFrom(dynamic paths) { + // TODO: Implement factory loading + } + + /// Setup an after resolving listener, or fire immediately if already resolved. + @protected + void callAfterResolving(String name, Function callback) { + // TODO: Implement after resolving + } + + /// Register migration paths to be published by the publish command. + @protected + void publishesMigrations(List paths, [dynamic groups]) { + // TODO: Implement migration publishing + } + + /// Register paths to be published by the publish command. + @protected + void registerPublishables(Map paths, [dynamic groups]) { + // TODO: Implement path publishing + } + + /// Laravel API compatibility method - forwards to registerPublishables + @protected + @Deprecated('Use registerPublishables instead') + void publishes(Map paths, [dynamic groups]) => + registerPublishables(paths, groups); + + /// Ensure the publish array for the service provider is initialized. + @protected + void ensurePublishArrayInitialized(String className) { + // TODO: Implement publish array initialization + } + + /// Add a publish group / tag to the service provider. + @protected + void addPublishGroup(String group, Map paths) { + // TODO: Implement publish group addition + } + + /// Get the paths to publish. + Map pathsToPublish([String? provider, String? group]) { + // TODO: Implement paths to publish + return {}; + } + + /// Get the paths for the provider or group (or both). + @protected + Map pathsForProviderOrGroup(String? provider, String? group) { + // TODO: Implement provider/group paths + return {}; + } + + /// Get the paths for the provider and group. + @protected + Map pathsForProviderAndGroup(String provider, String group) { + // TODO: Implement provider and group paths + return {}; + } + + /// Get the service providers available for publishing. + List publishableProviders() { + // TODO: Implement publishable providers + return []; + } + + /// Get the migration paths available for publishing. + List publishableMigrationPaths() { + return List.from(ServiceProviderStatic.publishableMigrationPaths); + } + + /// Get the groups available for publishing. + List publishableGroups() { + // TODO: Implement publishable groups + return []; + } + + /// Register the package's custom Artisan commands. + void commands(List commands) { + // TODO: Implement command registration + } + + /// Get the services provided by the provider. + List provides() => []; + + /// Get the events that trigger this service provider to register. + List when() => []; + + /// Determine if the provider is deferred. + bool isDeferred() => false; + + /// Get the default providers for a Laravel application. + List defaultProviders() { + // TODO: Implement default providers + return []; + } + + /// Add the given provider to the application's provider bootstrap file. + bool addProviderToBootstrapFile(String provider, [String? path]) { + // TODO: Implement provider bootstrap + return false; + } + + // Container convenience methods - these are extensions to Laravel's spec + // to make working with Dart's type system more ergonomic + + /// Register a singleton binding in the container. + void singleton(T instance) { + app.container.registerSingleton(instance); + } + + /// Register a binding in the container. + void bind(T Function(Container) factory) { + app.container.registerFactory(factory); + } + + /// Get a service from the container. + T make([Type? type]) { + return app.container.make(type); + } + + /// Determine if a service exists in the container. + bool has() { + return app.container.has(); + } + + /// Register a tagged binding in the container. + void tag(List abstracts, List tags) { + app.startupHooks.add((app) { + for (var type in abstracts) { + for (var tag in tags) { + app.container.registerSingleton(app.container.make(type), as: tag); + } + } + }); + } + + /// Register an event listener. + void listen(String event, RequestHandler listener) { + app.startupHooks.add((app) { + app.fallback((req, res) { + if (req.uri?.path == event) { + return listener(req, res); + } + return true; + }); + }); + } + + /// Register a middleware. + void middleware(String name, RequestHandler handler) { + app.startupHooks.add((app) { + app.responseFinalizers.add(handler); + }); + } +} diff --git a/packages/support/lib/src/providers/service_provider_static.dart b/packages/support/lib/src/providers/service_provider_static.dart new file mode 100644 index 0000000..be0bdbc --- /dev/null +++ b/packages/support/lib/src/providers/service_provider_static.dart @@ -0,0 +1,11 @@ +/// Mixin that provides static members for ServiceProvider +mixin ServiceProviderStatic { + /// The paths that should be published. + static final Map> publishes = {}; + + /// The paths that should be published by group. + static final Map> publishGroups = {}; + + /// The migration paths available for publishing. + static final List publishableMigrationPaths = []; +} diff --git a/packages/support/lib/src/providers/service_provider_support.dart b/packages/support/lib/src/providers/service_provider_support.dart new file mode 100644 index 0000000..15920a3 --- /dev/null +++ b/packages/support/lib/src/providers/service_provider_support.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:platform_core/core.dart'; +import 'service_provider.dart'; + +/// Storage class for service provider state +class ServiceProviderStorage { + /// The registered service providers + final Map providers = {}; + + /// The loaded provider types + final Set loaded = {}; + + /// The deferred services and their dependencies + final Map> deferred = {}; +} + +/// Extension that adds service provider support to [Application]. +extension ServiceProviderSupport on Application { + /// Get the provider storage instance. + ServiceProviderStorage get _storage { + if (!container.has()) { + container.registerSingleton(ServiceProviderStorage()); + } + return container.make(); + } + + /// Register a service provider with the application. + Future registerProvider(ServiceProvider provider) async { + provider.app = this; + + var provides = provider.provides(); + for (var service in provides) { + _storage.providers[service] = provider; + if (provider.isDeferred()) { + _storage.deferred[service] = provider.when(); + } + } + + if (!provider.isDeferred()) { + await _bootProvider(provider); + } + } + + /// Boot a service provider. + Future _bootProvider(ServiceProvider provider) async { + if (_storage.loaded.contains(provider.runtimeType)) return; + + try { + // Boot dependencies first + for (var dependency in provider.when()) { + await resolveProvider(dependency); + } + + // Call booting callbacks + provider.callBootingCallbacks(); + + // Register the provider + await Future.sync(() => provider.register()); + + // Execute any startup hooks that were registered during registration + for (var hook in startupHooks.toList()) { + await hook(this); + } + startupHooks.clear(); + + // Boot the provider + await Future.sync(() => provider.boot()); + + // Call booted callbacks + provider.callBootedCallbacks(); + + _storage.loaded.add(provider.runtimeType); + } catch (e) { + // If registration fails, remove from loaded providers + _storage.loaded.remove(provider.runtimeType); + rethrow; + } + } + + /// Resolve a service provider. + Future resolveProvider(String service) async { + var provider = _storage.providers[service]; + if (provider != null && !_storage.loaded.contains(provider.runtimeType)) { + await _bootProvider(provider); + } + } +} diff --git a/packages/support/pubspec.yaml b/packages/support/pubspec.yaml index 0b62651..ab6b996 100644 --- a/packages/support/pubspec.yaml +++ b/packages/support/pubspec.yaml @@ -1,15 +1,20 @@ name: platform_support description: Protevus Platform support package. version: 9.0.0 -# repository: https://github.com/my_org/my_repo +homepage: https://protevus.com +documentation: https://docs.protevus.com +repository: https://git.protevus.com/protevus/platform/src/branch/main/packages/support environment: sdk: ^3.5.4 -# Add regular dependencies here. dependencies: - # path: ^1.8.0 + platform_container: ^9.0.0 + platform_core: ^9.0.0 + meta: ^1.16.0 + collection: ^1.19.1 dev_dependencies: lints: ^4.0.0 test: ^1.24.0 + http: ^1.2.0 diff --git a/packages/support/test/providers_http_test.dart b/packages/support/test/providers_http_test.dart new file mode 100644 index 0000000..8db365c --- /dev/null +++ b/packages/support/test/providers_http_test.dart @@ -0,0 +1,129 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/container.dart'; +import 'package:platform_support/providers.dart'; +import 'package:test/test.dart'; +import 'package:http/http.dart' as http; + +// Test service provider with HTTP functionality +class HttpTestProvider extends ServiceProvider { + final events = []; + final middlewareCalls = []; + + @override + void register() { + super.register(); + + // Add a route handler + app.fallback((req, res) { + // Record middleware call + middlewareCalls.add(req.uri?.path ?? ''); + + if (req.uri?.path == '/test') { + events.add('route-hit'); + res.write('test'); + return false; + } + return true; + }); + } + + @override + void boot() { + super.boot(); + } + + @override + List provides() => ['http-test']; +} + +void main() { + group('ServiceProvider HTTP Tests', () { + late Application app; + late PlatformHttp server; + late HttpTestProvider provider; + late int port; + + setUp(() async { + app = Application(reflector: const EmptyReflector()); + server = PlatformHttp(app); + provider = HttpTestProvider(); + + // Register provider before starting server + await app.registerProvider(provider); + + // Start server on random port + await server.startServer('127.0.0.1', 0); + port = server.server?.port ?? 0; + expect(port, isNot(0), reason: 'Server should be assigned a port'); + + // Wait a bit for server to be ready + await Future.delayed(Duration(milliseconds: 100)); + }); + + tearDown(() async { + await server.close(); + await app.close(); + }); + + test('routes registered by provider work', () async { + expect(provider.events, isEmpty, + reason: 'No events should be recorded yet'); + + // Make request to test route + var response = await http.get(Uri.parse('http://127.0.0.1:$port/test')); + + // Wait a bit for async handlers to complete + await Future.delayed(Duration(milliseconds: 100)); + + expect(response.statusCode, equals(200), + reason: 'Should get successful response'); + expect(response.body, equals('test'), + reason: 'Should get expected response body'); + expect(provider.events, contains('route-hit'), + reason: 'Route should be hit'); + }); + + test('middleware registered by provider works', () async { + expect(provider.middlewareCalls, isEmpty, + reason: 'No middleware calls should be recorded yet'); + + // Make request to trigger middleware + await http.get(Uri.parse('http://127.0.0.1:$port/test')); + + // Wait a bit for async handlers to complete + await Future.delayed(Duration(milliseconds: 100)); + + expect(provider.middlewareCalls, contains('/test'), + reason: 'Middleware should record request path'); + }); + + test('multiple requests are handled correctly', () async { + // Make multiple requests + await Future.wait([ + http.get(Uri.parse('http://127.0.0.1:$port/test')), + http.get(Uri.parse('http://127.0.0.1:$port/test')), + http.get(Uri.parse('http://127.0.0.1:$port/test')) + ]); + + // Wait a bit for async handlers to complete + await Future.delayed(Duration(milliseconds: 100)); + + expect(provider.events.length, equals(3), + reason: 'Should record 3 route hits'); + expect(provider.middlewareCalls.length, equals(3), + reason: 'Should record 3 middleware calls'); + }); + + test('middleware runs for all requests', () async { + // Make request to non-existent route + await http.get(Uri.parse('http://127.0.0.1:$port/not-found')); + + // Wait a bit for async handlers to complete + await Future.delayed(Duration(milliseconds: 100)); + + expect(provider.middlewareCalls, contains('/not-found'), + reason: 'Middleware should run even for non-existent routes'); + }); + }); +} diff --git a/packages/support/test/providers_test.dart b/packages/support/test/providers_test.dart new file mode 100644 index 0000000..8fbdebe --- /dev/null +++ b/packages/support/test/providers_test.dart @@ -0,0 +1,236 @@ +import 'package:platform_core/core.dart'; +import 'package:platform_core/http.dart'; +import 'package:platform_container/container.dart'; +import 'package:platform_support/providers.dart'; +import 'package:test/test.dart'; + +// Test service class +class TestService { + final String message; + TestService(this.message); +} + +// Service registry for testing +class ServiceRegistry { + static final Map providers = {}; + static final Map booted = {}; + + static void register(String key, ServiceProvider provider) { + providers[key] = provider; + } + + static void markBooted(String key) { + booted[key] = true; + } + + static ServiceProvider? get(String key) { + return providers[key]; + } + + static bool isBooted(String key) { + return booted[key] == true; + } + + static void clear() { + providers.clear(); + booted.clear(); + } +} + +// Basic service provider for testing +class TestServiceProvider extends ServiceProvider { + bool registerCalled = false; + bool bootCalled = false; + final String message; + TestService? _service; + + TestServiceProvider([this.message = 'test']); + + @override + void register() { + super.register(); + registerCalled = true; + _service = TestService(message); + ServiceRegistry.register('test-service', this); + } + + @override + void boot() { + super.boot(); + bootCalled = true; + ServiceRegistry.markBooted('test-service'); + } + + @override + List provides() => ['test-service']; + + TestService? getService() => _service; +} + +// Deferred service provider for testing +class DeferredTestProvider extends DeferredServiceProvider { + bool registerCalled = false; + bool bootCalled = false; + TestService? _service; + + @override + void register() { + super.register(); + registerCalled = true; + _service = TestService('deferred'); + ServiceRegistry.register('deferred-service', this); + } + + @override + void boot() { + super.boot(); + bootCalled = true; + ServiceRegistry.markBooted('deferred-service'); + } + + @override + List provides() => ['deferred-service']; + + TestService? getService() => _service; +} + +// Provider with dependencies for testing +class DependentProvider extends ServiceProvider { + bool registerCalled = false; + bool bootCalled = false; + TestService? _service; + + @override + void register() { + super.register(); + registerCalled = true; + + // Get the base service + var baseProvider = + ServiceRegistry.get('test-service') as TestServiceProvider?; + if (baseProvider != null && ServiceRegistry.isBooted('test-service')) { + var baseService = baseProvider.getService(); + if (baseService != null) { + _service = TestService('dependent: ${baseService.message}'); + } + } + ServiceRegistry.register('dependent-service', this); + } + + @override + void boot() { + super.boot(); + bootCalled = true; + ServiceRegistry.markBooted('dependent-service'); + } + + @override + List provides() => ['dependent-service']; + + @override + List dependencies() => ['test-service']; + + TestService? getService() => _service; +} + +void main() { + group('ServiceProvider Tests', () { + late Application app; + + setUp(() { + app = Application(reflector: const EmptyReflector()); + ServiceRegistry.clear(); + }); + + tearDown(() async { + await app.close(); + ServiceRegistry.clear(); + }); + + test('registers and boots non-deferred provider immediately', () async { + var provider = TestServiceProvider(); + await app.registerProvider(provider); + + expect(provider.registerCalled, isTrue, + reason: 'register() should be called'); + expect(provider.bootCalled, isTrue, reason: 'boot() should be called'); + expect(provider.getService(), isNotNull, + reason: 'Service should be created'); + expect(provider.getService()?.message, equals('test')); + }); + + test('defers loading of deferred provider', () async { + var provider = DeferredTestProvider(); + await app.registerProvider(provider); + + expect(provider.registerCalled, isFalse, + reason: 'register() should not be called yet'); + expect(provider.bootCalled, isFalse, + reason: 'boot() should not be called yet'); + expect(provider.getService(), isNull, + reason: 'Service should not be created yet'); + }); + + test('loads deferred provider when resolved', () async { + var provider = DeferredTestProvider(); + await app.registerProvider(provider); + await app.resolveProvider('deferred-service'); + + expect(provider.registerCalled, isTrue, + reason: 'register() should be called after resolution'); + expect(provider.bootCalled, isTrue, + reason: 'boot() should be called after resolution'); + expect(provider.getService(), isNotNull, + reason: 'Service should be created after resolution'); + expect(provider.getService()?.message, equals('deferred')); + }); + + test('resolves dependencies before booting provider', () async { + var baseProvider = TestServiceProvider('base'); + var dependentProvider = DependentProvider(); + + // Register base provider first to ensure it's ready + await app.registerProvider(baseProvider); + await app.registerProvider(dependentProvider); + + expect(baseProvider.registerCalled, isTrue, + reason: 'Base provider register() should be called'); + expect(baseProvider.bootCalled, isTrue, + reason: 'Base provider boot() should be called'); + expect(dependentProvider.registerCalled, isTrue, + reason: 'Dependent provider register() should be called'); + expect(dependentProvider.bootCalled, isTrue, + reason: 'Dependent provider boot() should be called'); + + expect( + dependentProvider.getService()?.message, equals('dependent: base')); + }); + + test('singleton registration works correctly', () async { + var provider = TestServiceProvider(); + await app.registerProvider(provider); + + var service1 = provider.getService(); + var service2 = provider.getService(); + + expect(identical(service1, service2), isTrue, + reason: 'Should get same instance'); + }); + + test('provider storage persists across resolutions', () async { + var provider = DeferredTestProvider(); + await app.registerProvider(provider); + + // First resolution + await app.resolveProvider('deferred-service'); + var service1 = provider.getService(); + + // Second resolution + await app.resolveProvider('deferred-service'); + var service2 = provider.getService(); + + expect(identical(service1, service2), isTrue, + reason: 'Should get same instance across resolutions'); + }); + }); +} diff --git a/sandbox/eventbus/.github/workflows/dart.yml b/sandbox/eventbus/.github/workflows/dart.yml new file mode 100644 index 0000000..21b7ff6 --- /dev/null +++ b/sandbox/eventbus/.github/workflows/dart.yml @@ -0,0 +1,54 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Dart + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Note: This workflow uses the latest stable version of the Dart SDK. + # You can specify other versions if desired, see documentation here: + # https://github.com/dart-lang/setup-dart/blob/main/README.md + # - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + + - name: Flutter action + # You may pin to the exact commit or the version. + # uses: subosito/flutter-action@4389e6cbc6cb8a4b18c628ff96ff90be0e926aa8 + uses: subosito/flutter-action@v1.5.3 + + - name: Install dependencies + run: dart pub get + + # Uncomment this step to verify the use of 'dart format' on each commit. + # - name: Verify formatting + # run: dart format --output=none --set-exit-if-changed . + + # Consider passing '--fatal-infos' for slightly stricter analysis. + - name: Analyze project source + run: dart analyze + + # Your project will need to have tests in test/ and a dependency on + # package:test for this step to succeed. Note that Flutter projects will + # want to change this to 'flutter test'. + - name: Run tests + run: flutter test --coverage + + - name: Install lcov + run: sudo apt-get install -y lcov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + file: coverage/lcov.info diff --git a/sandbox/eventbus/.gitignore b/sandbox/eventbus/.gitignore new file mode 100644 index 0000000..3d34218 --- /dev/null +++ b/sandbox/eventbus/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +coverage diff --git a/sandbox/eventbus/.metadata b/sandbox/eventbus/.metadata new file mode 100644 index 0000000..0bb64e4 --- /dev/null +++ b/sandbox/eventbus/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7e9793dee1b85a243edd0e06cb1658e98b077561 + channel: stable + +project_type: package diff --git a/sandbox/eventbus/.vscode/settings.json b/sandbox/eventbus/.vscode/settings.json new file mode 100644 index 0000000..52472e3 --- /dev/null +++ b/sandbox/eventbus/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "codecov", + "devcraft", + "shouldly" + ] +} \ No newline at end of file diff --git a/sandbox/eventbus/CHANGELOG.md b/sandbox/eventbus/CHANGELOG.md new file mode 100644 index 0000000..5302c51 --- /dev/null +++ b/sandbox/eventbus/CHANGELOG.md @@ -0,0 +1,67 @@ +## 0.6.2 + +* update `logger` dependency + +## 0.6.1 + +* [Add] `allowLogging` to control logging (thanks [@hatemragab](https://github.com/hatemragab)) + +## 0.6.0 + +* **[Change]** fire `EmptyEvent` after each event to keep stream empty. +* [Add] [Logger](https://pub.dev/packages/logger) + +## 0.5.0 + +* [Add] timestamp for each `event`. + +## 0.4.0 + +* [Add] mapper + +## 0.3.0+3 + +* Downgrade clock version + +## 0.3.0+2 + +* Improve documentation + +## 0.3.0+1 + +* Improve documentation + +## 0.3.0 + +* [Add] `EventCompletionEvent` +* Improve documentation + +## 0.2.2 + +* [Fix] `clock` dependency + +## 0.2.1 + +* [Add] debug logging + +## 0.2.0 + +* [Add] history + +## 0.1.0 + +* [Add] distinct + +## 0.0.2+1 + +* [Fix] GitHub repository link + +## 0.0.2 + +* [Remove] `id` of `AppEvent` + +## 0.0.1 + +* Initial release. +* [Add] `EventBus` +* [Add] `AppEvent` superclass diff --git a/sandbox/eventbus/LICENSE b/sandbox/eventbus/LICENSE new file mode 100644 index 0000000..c560cf3 --- /dev/null +++ b/sandbox/eventbus/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Andrew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sandbox/eventbus/README.md b/sandbox/eventbus/README.md new file mode 100644 index 0000000..4c8f4bd --- /dev/null +++ b/sandbox/eventbus/README.md @@ -0,0 +1,98 @@ +# EventBus: Events for Dart/Flutter + +[![pub package](https://img.shields.io/pub/v/event_bus_plus.svg?label=event_bus_plus&color=blue)](https://pub.dev/packages/event_bus_plus) +[![codecov](https://codecov.io/gh/AndrewPiterov/event_bus_plus/branch/main/graph/badge.svg?token=VM9LTJXGQS)](https://codecov.io/gh/AndrewPiterov/event_bus_plus) +[![Dart](https://github.com/AndrewPiterov/event_bus_plus/actions/workflows/dart.yml/badge.svg)](https://github.com/AndrewPiterov/event_bus_plus/actions/workflows/dart.yml) + +**EventBus** is an open-source library for Dart and Flutter using the **publisher/subscriber** pattern for loose coupling. **EventBus** enables central communication to decoupled classes with just a few lines of code – simplifying the code, removing dependencies, and speeding up app development. + +event bus publish subscribe + +## Your benefits using EventBus: It… +- simplifies the communication between components; +- decouples event senders and receivers; +- performs well with UI artifacts (e.g. Widgets, Controllers); +- avoids complex and error-prone dependencies and life cycle issues. + + +event bus plus + +### Define the app's events + +```dart +// Initialize the Service Bus +IAppEventBus eventBus = AppEventBus(); + +// Define your app events +final event = FollowEvent('@devcraft.ninja'); +final event = CommentEvent('Awesome package 😎'); +``` + +### Subscribe + +```dart +// listen the latest event +final sub = eventBus.last$ + .listen((AppEvent event) { /*do something*/ }); + +// Listen particular event +final sub2 = eventBus.on() + .listen((e) { /*do something*/ }); +``` + +### Publish + +```dart +// fire the event +eventBus.fire(event); +``` + +### Watch events in progress + +```dart +// start watch the event till its completion +eventBus.watch(event); + +// and check the progress +eventBus.isInProgress(); + +// or listen stream to check the processing +eventBus.inProgress$.map((List events) => + events.whereType().isNotEmpty); + +// complete +_eventBus.complete(event); + +// or complete with completion event +_eventBus.complete(event, nextEvent: SomeAnotherEvent); +``` + +## History + +```dart +final events = eventBus.history; +``` + +## Mapping + +```dart +final eventBus = bus = EventBus( + map: { + SomeEvent: [ + (e) => SomeAnotherEvent(), + ], + }, + ); +``` + +## Contributing + +We accept the following contributions: + +* Improving the documentation +* [Reporting issues](https://github.com/AndrewPiterov/event_bus_plus/issues/new) +* Fixing bugs + +## Maintainers + +* [Andrew Piterov](mailto:contact@andrewpiterov.com?subject=[GitHub]%20Source%20Dart%event_bus_plus) diff --git a/sandbox/eventbus/analysis_options.yaml b/sandbox/eventbus/analysis_options.yaml new file mode 100644 index 0000000..67016e9 --- /dev/null +++ b/sandbox/eventbus/analysis_options.yaml @@ -0,0 +1,224 @@ +include: package:flutter_lints/flutter.yaml +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: true + errors: + # Close instances of dart.core.Sink. + close_sinks: error + # Cancel instances of dart.async.StreamSubscription. + cancel_subscriptions: error + # treat missing required parameters as a error (not a hint) + missing_required_param: error + # treat missing returns as a error (not a hint) + missing_return: error + # allow having TODOs in the code and treat as warning + todo: warning + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: ignore + # Ignore analyzer hints for updating pubspecs when using Future or + # Stream and not importing dart:async + # Please see https://github.com/flutter/flutter/pull/24528 for details. + sdk_version_async_exported_from_core: ignore + # await + unawaited_futures: error + avoid_print: warning + always_declare_return_types: error + unrelated_type_equality_checks: error + implementation_imports: error + require_trailing_commas: warning + avoid_slow_async_io: error + use_build_context_synchronously: error + sized_box_for_whitespace: error + dead_code: warning + invalid_assignment: error + + exclude: + - "bin/cache/**" + # the following two are relative to the stocks example and the flutter package respectively + # see https://github.com/dart-lang/sdk/issues/28463 + - "lib/i18n/messages_*.dart" + - "lib/src/http/**" + - "build/**" + - "lib/**.freezed.dart" + - "lib/**.g.dart" + - "test/**" + - "example/**" + +linter: + rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + - always_require_non_null_named_parameters + # - always_specify_types + - annotate_overrides + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + # - avoid_as # required for implicit-casts: true + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # we do this commonly + # - avoid_catching_errors # we do this commonly + - avoid_classes_with_only_static_members + # - avoid_double_and_int_checks # only useful when targeting JS runtime + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + # - avoid_implementing_value_types # not yet tested + - avoid_init_to_null + # - avoid_js_rounded_ints # only useful when targeting JS runtime + - avoid_null_checks_in_equality_operators + # - avoid_positional_boolean_parameters # not yet tested + - avoid_print # not yet tested + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + # - avoid_redundant_argument_values # not yet tested + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + # - avoid_returning_null # there are plenty of valid reasons to return null + # - avoid_returning_null_for_future # not yet tested + - avoid_returning_null_for_void + # - avoid_returning_this # there are plenty of valid reasons to return this + # - avoid_setters_without_getters # not yet tested + - avoid_shadowing_type_parameters # not yet tested + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + # - avoid_unnecessary_containers # not yet tested + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # not yet tested + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # not yet tested + # - close_sinks # not reliable enough + # - comment_references # blocked on https://github.com/flutter/flutter/issues/20765 + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + # - curly_braces_in_flow_control_structures # not yet tested + # - diagnostic_describe_all_properties # not yet tested + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + # - file_names # not yet tested + - flutter_style_todos + - hash_and_equals + - implementation_imports + # - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811 + - iterable_contains_unrelated_type + # - join_return_with_assignment # not yet tested + - library_names + - library_prefixes + # - lines_longer_than_80_chars # not yet tested + - list_remove_unrelated_type + # - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181 + # - missing_whitespace_between_adjacent_strings # not yet tested + - no_adjacent_strings_in_list + - no_duplicate_case_values + # - no_logic_in_create_state # not yet tested + # - no_runtimeType_toString # not yet tested + - non_constant_identifier_names + # - null_closures # not yet tested + - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + # - only_throw_errors # https://github.com/flutter/flutter/issues/5792 + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not yet tested + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # not yet tested + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + - prefer_equal_for_default_values + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + # - prefer_function_declarations_over_variables # not yet tested + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # not yet tested + # - prefer_interpolation_to_compose_strings # not yet tested + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + # - prefer_null_aware_operators # disable until NNBD, see https://github.com/flutter/flutter/pull/32711#issuecomment-492930932 + # - prefer_relative_imports # not yet tested + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + # - provide_deprecation_message # not yet tested + - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - slash_for_doc_comments + # - sort_child_properties_last # not yet tested + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + - unawaited_futures # too many false positives + - unnecessary_await_in_return # not yet tested + - unnecessary_brace_in_string_interps + - unnecessary_const + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + # - unsafe_html # not yet tested + - use_full_hex_values_for_flutter_colors + # - use_function_type_syntax_for_parameters # not yet tested + # - use_key_in_widget_constructors # not yet tested + - use_late_for_private_fields_and_variables + - use_rethrow_when_possible + # - use_setters_to_change_properties # not yet tested + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + - void_checks + +dart_code_metrics: + rules: + - newline-before-return: + severity: style + - no-boolean-literal-compare: + severity: error + anti-patterns: + - long-method: + severity: warning diff --git a/sandbox/eventbus/doc/pub_sub.webp b/sandbox/eventbus/doc/pub_sub.webp new file mode 100644 index 0000000..8e13702 Binary files /dev/null and b/sandbox/eventbus/doc/pub_sub.webp differ diff --git a/sandbox/eventbus/doc/video_presentation.gif b/sandbox/eventbus/doc/video_presentation.gif new file mode 100644 index 0000000..0ef1981 Binary files /dev/null and b/sandbox/eventbus/doc/video_presentation.gif differ diff --git a/sandbox/eventbus/lib/event_bus.dart b/sandbox/eventbus/lib/event_bus.dart new file mode 100644 index 0000000..e19ecc6 --- /dev/null +++ b/sandbox/eventbus/lib/event_bus.dart @@ -0,0 +1,3 @@ +library event_bus; + +export 'res/res.dart'; diff --git a/sandbox/eventbus/lib/res/app_event.dart b/sandbox/eventbus/lib/res/app_event.dart new file mode 100644 index 0000000..8182351 --- /dev/null +++ b/sandbox/eventbus/lib/res/app_event.dart @@ -0,0 +1,29 @@ +import 'package:clock/clock.dart'; +import 'package:equatable/equatable.dart'; + +/// The base class for all events +abstract class AppEvent extends Equatable { + /// Create the event + const AppEvent(); + + /// The event time + DateTime get timestamp => clock.now(); +} + +/// The event completion event +class EventCompletionEvent extends AppEvent { + /// Create the event + const EventCompletionEvent(this.event); + + /// The event that is completed + final AppEvent event; + + @override + List get props => [event]; +} + +/// The empty event +class EmptyEvent extends AppEvent { + @override + List get props => []; +} diff --git a/sandbox/eventbus/lib/res/event_bus.dart b/sandbox/eventbus/lib/res/event_bus.dart new file mode 100644 index 0000000..d23bb2c --- /dev/null +++ b/sandbox/eventbus/lib/res/event_bus.dart @@ -0,0 +1,212 @@ +import 'package:angel3_reactivex/subjects.dart'; +import 'package:logger/logger.dart'; + +import 'app_event.dart'; +import 'history_entry.dart'; +import 'subscription.dart'; + +/// The event bus interface +abstract class IEventBus { + /// Whether the event bus is busy + bool get isBusy; + + /// Whether the event bus is busy + Stream get isBusy$; + + /// The last event + AppEvent? get last; + + /// The last event + Stream get last$; + + /// The list of events that are in progress + Stream> get inProgress$; + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Stream on(); + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Stream whileInProgress(); + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Subscription respond(Responder responder); + + /// The history of events + List get history; + + /// Fire a event + void fire(AppEvent event); + + /// Fire a event and wait for it to be completed + void watch(AppEvent event); + + /// Complete a event + void complete(AppEvent event, {AppEvent? nextEvent}); + + /// + bool isInProgress(); + + /// Reset the event bus + void reset(); + + /// Dispose the event bus + void dispose(); + + /// Clear the history + void clearHistory(); +} + +/// The event bus implementation +class EventBus implements IEventBus { + /// Create the event bus + EventBus({ + this.maxHistoryLength = 100, + this.map = const {}, + this.allowLogging = false, + }); + + final _logger = Logger(); + + /// The maximum length of history + final int maxHistoryLength; + + /// allow to log all events this when you call [fire] + /// the event will be in console log + final bool allowLogging; + + /// The map of events + final Map> map; + + @override + bool get isBusy => _inProgress.value.isNotEmpty; + @override + Stream get isBusy$ => _inProgress.map((event) => event.isNotEmpty); + + final _lastEventSubject = BehaviorSubject(); + @override + AppEvent? get last => _lastEventSubject.valueOrNull; + @override + Stream get last$ => _lastEventSubject.distinct(); + + final _inProgress = BehaviorSubject>.seeded([]); + List get _isInProgressEvents => _inProgress.value; + @override + Stream> get inProgress$ => _inProgress; + + @override + List get history => List.unmodifiable(_history); + final List _history = []; + + @override + void fire(AppEvent event) { + if (_history.length >= maxHistoryLength) { + _history.removeAt(0); + } + _history.add(EventBusHistoryEntry(event, event.timestamp)); + // 1. Fire the event + _lastEventSubject.add(event); + // 2. Map if needed + _map(event); + // 3. Reset stream + _lastEventSubject.add(EmptyEvent()); + if (allowLogging) { + _logger.d(' ⚡️ [${event.timestamp}] $event'); + } + } + + @override + void watch(AppEvent event) { + fire(event); + _inProgress.add([ + ..._isInProgressEvents, + event, + ]); + } + + @override + void complete(AppEvent event, {AppEvent? nextEvent}) { + // complete the event + if (_isInProgressEvents.any((e) => e == event)) { + final newArr = _isInProgressEvents.toList() + ..removeWhere((e) => e == event); + _inProgress.add(newArr); + fire(EventCompletionEvent(event)); + } + + // fire next event if any + if (nextEvent != null) { + fire(nextEvent); + } + } + + @override + bool isInProgress() { + return _isInProgressEvents.whereType().isNotEmpty; + } + + @override + Stream on() { + if (T == dynamic) { + return _lastEventSubject.stream as Stream; + } else { + return _lastEventSubject.stream.where((event) => event is T).cast(); + } + } + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + /// + /// When [T] is not given or given as `dynamic`, it listens to all events regardless of the type. + /// Returns [Subscription], which can be disposed to cancel all the subscription registered to itself. + @override + Subscription respond(Responder responder) => + Subscription(_lastEventSubject).respond(responder); + + @override + Stream whileInProgress() { + return _inProgress.map((events) { + return events.whereType().isNotEmpty; + }); + } + + void _map(AppEvent? event) { + if (event == null) { + return; + } + + final functions = map[event.runtimeType] ?? []; + if (functions.isEmpty) { + return; + } + + for (final func in functions) { + final newEvent = func(event); + if (newEvent.runtimeType == event.runtimeType) { + if (allowLogging) { + _logger.d( + ' 🟠 SKIP EVENT: ${newEvent.runtimeType} => ${event.runtimeType}', + ); + } + continue; + } + fire(newEvent); + } + } + + @override + void clearHistory() { + _history.clear(); + } + + @override + void reset() { + clearHistory(); + _inProgress.add([]); + _lastEventSubject.add(EmptyEvent()); + } + + @override + void dispose() { + _inProgress.close(); + _lastEventSubject.close(); + } +} diff --git a/sandbox/eventbus/lib/res/history_entry.dart b/sandbox/eventbus/lib/res/history_entry.dart new file mode 100644 index 0000000..65c7a11 --- /dev/null +++ b/sandbox/eventbus/lib/res/history_entry.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +import 'app_event.dart'; + +/// The history entry +class EventBusHistoryEntry extends Equatable { + /// The history entry + const EventBusHistoryEntry(this.event, this.timestamp); + + /// The event + final AppEvent event; + + /// The timestamp + final DateTime timestamp; + + @override + List get props => [event, timestamp]; +} diff --git a/sandbox/eventbus/lib/res/res.dart b/sandbox/eventbus/lib/res/res.dart new file mode 100644 index 0000000..c2e36e1 --- /dev/null +++ b/sandbox/eventbus/lib/res/res.dart @@ -0,0 +1,4 @@ +export 'app_event.dart'; +export 'event_bus.dart'; +export 'history_entry.dart'; +export 'subscription.dart'; diff --git a/sandbox/eventbus/lib/res/subscription.dart b/sandbox/eventbus/lib/res/subscription.dart new file mode 100644 index 0000000..74bbb41 --- /dev/null +++ b/sandbox/eventbus/lib/res/subscription.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +/// The function/method signature for the event handler +typedef Responder = void Function(T event); + +/// The class manages the subscription to event bus +class Subscription { + /// Create the subscription + /// + /// Should barely used directly, to subscribe to event bus, use `EventBus.respond`. + Subscription(this._stream); + + /// Returns an instance that indicates there is no subscription + factory Subscription.empty() => const _EmptySubscription(); + + final Stream _stream; + + /// Subscriptions that registered to event bus + final List subscriptions = []; + + Stream _cast() { + if (T == dynamic) { + return _stream as Stream; + } else { + return _stream.where((event) => event is T).cast(); + } + } + + /// Register a [responder] to event bus for the event type [T]. + /// If [T] is omitted or given as `dynamic`, it listens to all events that published on [EventBus]. + /// + /// Method call can be safely chained, and the order doesn't matter. + /// + /// ``` + /// eventBus + /// .respond(responderA) + /// .respond(responderB); + /// ``` + Subscription respond(Responder responder) { + subscriptions.add(_cast().listen(responder)); + return this; + } + + /// Cancel all the registered subscriptions. + /// After calling this method, all the events published won't be delivered to the cleared responders any more. + /// + /// No harm to call more than once. + void dispose() { + if (subscriptions.isEmpty) { + return; + } + for (final s in subscriptions) { + s.cancel(); + } + subscriptions.clear(); + } +} + +class _EmptySubscription implements Subscription { + const _EmptySubscription(); + static final List emptyList = + List.unmodifiable([]); + + @override + void dispose() {} + + @override + Subscription respond(responder) => throw Exception('Not supported'); + + @override + List get subscriptions => emptyList; + + @override + Stream _cast() => throw Exception('Not supported'); + + @override + Stream get _stream => throw Exception('Not supported'); +} diff --git a/sandbox/eventbus/pubspec.yaml b/sandbox/eventbus/pubspec.yaml new file mode 100644 index 0000000..bb71c80 --- /dev/null +++ b/sandbox/eventbus/pubspec.yaml @@ -0,0 +1,20 @@ +name: angel3_event_bus +description: Event Bus for Dart. +version: 0.6.2 +homepage: https://github.com/AndrewPiterov/event_bus_plus + +environment: + sdk: '>=2.17.1 <4.0.0' + +dependencies: + clock: ^1.1.0 + equatable: ^2.0.5 + logger: ^2.0.2+1 + angel3_reactivex: ^0.27.5 + +dev_dependencies: + flutter_lints: ^3.0.0 + given_when_then_unit_test: ^0.2.1 + shouldly: ^0.5.0+1 + test: ^1.22.0 + diff --git a/sandbox/eventbus/test/completion_test.dart b/sandbox/eventbus/test/completion_test.dart new file mode 100644 index 0000000..6105911 --- /dev/null +++ b/sandbox/eventbus/test/completion_test.dart @@ -0,0 +1,70 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'models.dart'; +import 'package:test/test.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('emit Follower Events', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + EmptyEvent(), + const FollowAppEvent('username3'), + EmptyEvent(), + const FollowAppEvent('username2'), + EmptyEvent(), + ])); + + bus.fire(const FollowAppEvent('username')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username2')); + }); + + test('start watch but not complete', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + ])); + + bus.watch(const FollowAppEvent('username')); + }); + + test('start watch and complete', () { + const watchable = FollowAppEvent('username3'); + expect( + bus.on(), + emitsInOrder([ + watchable, + EmptyEvent(), + const EventCompletionEvent(watchable), + EmptyEvent(), + const FollowSuccessfullyEvent(watchable), + EmptyEvent(), + ])); + + bus.watch(watchable); + bus.complete(watchable, + nextEvent: const FollowSuccessfullyEvent(watchable)); + }); + + // test('emit Follower Events', () { + // final watchable = FollowAppEvent('username3', id: '3'); + // expect( + // _bus.on(), + // emitsInAnyOrder([ + // FollowAppEvent('username3', id: '3'), + // FollowSuccessfullyAppEvent(watchable), + // ])); + + // _bus.watch(watchable); + // _bus.complete(watchable, withh: FollowSuccessfullyAppEvent(watchable)); + // }); +} diff --git a/sandbox/eventbus/test/distinct_test.dart b/sandbox/eventbus/test/distinct_test.dart new file mode 100644 index 0000000..5aeaf9f --- /dev/null +++ b/sandbox/eventbus/test/distinct_test.dart @@ -0,0 +1,58 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('Call once', () { + expectLater( + bus.last$, + emitsInOrder([ + const SomeEvent(), + EmptyEvent(), + const SomeAnotherEvent(), + EmptyEvent(), + ])); + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + }); + + // test('Call twice', () { + // const event = SomeEvent(); + // expectLater( + // bus.last$, + // emitsInOrder([ + // const SomeEvent(), + // EmptyEvent(), + // const SomeAnotherEvent(), + // EmptyEvent(), + // ])); + // bus.fire(event); + // bus.fire(event); + // bus.fire(const SomeAnotherEvent()); + // }); + + // test('Call three times', () { + // const event = SomeEvent(); + // expectLater( + // bus.last$, + // emitsInOrder([ + // const SomeEvent(), + // EmptyEvent(), + // const SomeAnotherEvent(), + // EmptyEvent(), + // ])); + // bus.fire(event); + // bus.fire(event); + // bus.fire(event); + // bus.fire(const SomeAnotherEvent()); + // }); +} diff --git a/sandbox/eventbus/test/empty_event_test.dart b/sandbox/eventbus/test/empty_event_test.dart new file mode 100644 index 0000000..050505e --- /dev/null +++ b/sandbox/eventbus/test/empty_event_test.dart @@ -0,0 +1,20 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:test/test.dart'; +import 'models.dart'; + +void main() { + final IEventBus _bus = EventBus(); + + test('Should fire Empty event', () { + expect( + _bus.last$, + emitsInOrder( + [ + const SomeEvent(), + EmptyEvent(), + ], + ), + ); + _bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/sandbox/eventbus/test/event_bus_test.dart b/sandbox/eventbus/test/event_bus_test.dart new file mode 100644 index 0000000..5d7a5b1 --- /dev/null +++ b/sandbox/eventbus/test/event_bus_test.dart @@ -0,0 +1,63 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('Empty event bus', () { + bus.isBusy.should.beFalse(); + }); + + when('start some event', () { + const event = FollowAppEvent('username'); + // const eventId = '1'; + + before(() { + bus.watch(event); + }); + + then('should be busy', () { + bus.isBusy.should.beTrue(); + }); + + then('should be in progress', () { + bus.isInProgress().should.beTrue(); + }); + + and('complete the event', () { + before(() { + bus.complete(event); + }); + + then('should not be busy', () { + bus.isBusy.should.beFalse(); + }); + + then('should not be in progress', () { + bus.isInProgress().should.not.beTrue(); + }); + }); + }); + + group('compare equality', () { + // test('compare two equal events - should not be equal', () { + // final event = FollowAppEvent('username'); + // final event2 = FollowAppEvent('username'); + // event.should.not.be(event2); + // }); + + test('compare two equal events - should be equal', () { + const event = FollowAppEvent('username'); + const event2 = FollowAppEvent('username'); + event.should.be(event2); + }); + }); +} diff --git a/sandbox/eventbus/test/history_test.dart b/sandbox/eventbus/test/history_test.dart new file mode 100644 index 0000000..9eaf709 --- /dev/null +++ b/sandbox/eventbus/test/history_test.dart @@ -0,0 +1,75 @@ +import 'package:clock/clock.dart'; +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/test.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(maxHistoryLength: 3); + }); + + test('Keep history', () { + final date = DateTime(2022, 9, 1); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + ]); + }); + }); + + test('Keep full history without debounce', () { + final date = DateTime(2022, 9, 1, 4, 59, 55); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + ]); + }); + }); + + test('Clear history', () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + + bus.clearHistory(); + + bus.history.should.beEmpty(); + }); + + group('History length', () { + test('Add more than max', () { + final date = DateTime(2022, 9, 1, 4, 59, 55); + + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + bus.fire(const NewCommentEvent('text')); + + bus.history.length.should.be(3); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + EventBusHistoryEntry(const NewCommentEvent('text'), date), + ]); + }); + }); + }); +} diff --git a/sandbox/eventbus/test/mapping/map_ignore_test.dart b/sandbox/eventbus/test/mapping/map_ignore_test.dart new file mode 100644 index 0000000..496aed7 --- /dev/null +++ b/sandbox/eventbus/test/mapping/map_ignore_test.dart @@ -0,0 +1,36 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; + +import '../models.dart'; + +void main() { + late IEventBus bus; + + before( + () { + bus = EventBus( + map: { + SomeEvent: [ + (e) => e, + (e) => const SomeAnotherEvent(), + ], + }, + ); + }, + ); + + test('does not emit the same event', () { + expect( + bus.last$, + emitsInOrder( + [ + const SomeEvent(), + const SomeAnotherEvent(), + EmptyEvent(), + ], + ), + ); + bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/sandbox/eventbus/test/mapping/map_test.dart b/sandbox/eventbus/test/mapping/map_test.dart new file mode 100644 index 0000000..f83ef02 --- /dev/null +++ b/sandbox/eventbus/test/mapping/map_test.dart @@ -0,0 +1,34 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; + +import '../models.dart'; + +void main() { + late IEventBus bus; + + before( + () { + bus = EventBus( + map: { + SomeEvent: [ + (e) => const SomeAnotherEvent(), + ], + }, + ); + }, + ); + + test('emits another', () { + expect( + bus.last$, + emitsInOrder( + [ + const SomeEvent(), + const SomeAnotherEvent(), + ], + ), + ); + bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/sandbox/eventbus/test/models.dart b/sandbox/eventbus/test/models.dart new file mode 100644 index 0000000..899c50f --- /dev/null +++ b/sandbox/eventbus/test/models.dart @@ -0,0 +1,42 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; + +class FollowAppEvent extends AppEvent { + const FollowAppEvent(this.username); + + final String username; + + @override + List get props => [username]; +} + +class FollowSuccessfullyEvent extends AppEvent { + const FollowSuccessfullyEvent(this.starting); + + final FollowAppEvent starting; + + @override + List get props => [starting]; +} + +class NewCommentEvent extends AppEvent { + const NewCommentEvent(this.text); + + final String text; + + @override + List get props => [text]; +} + +class SomeEvent extends AppEvent { + const SomeEvent(); + + @override + List get props => []; +} + +class SomeAnotherEvent extends AppEvent { + const SomeAnotherEvent(); + + @override + List get props => []; +} diff --git a/sandbox/eventbus/test/respond_test.dart b/sandbox/eventbus/test/respond_test.dart new file mode 100644 index 0000000..3c9b5cf --- /dev/null +++ b/sandbox/eventbus/test/respond_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:async'; +import 'dart:developer'; + +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('emit Some Event', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }); + + expect(ctrl.stream, emitsInOrder([1, 2])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + }); + + test('emit Some Events', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }); + + expect(ctrl.stream, emitsInOrder([1, 2, 1])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + bus.fire(FollowAppEvent('username2')); + }); + + test('emit all Event', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }).respond((event) { + log('event $event'); + ctrl.add(3); + }); + + expect(ctrl.stream, emitsInOrder([1, 3, 3, 2, 3, 3])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + }); +} diff --git a/sandbox/eventbus/test/streams_test.dart b/sandbox/eventbus/test/streams_test.dart new file mode 100644 index 0000000..1cec696 --- /dev/null +++ b/sandbox/eventbus/test/streams_test.dart @@ -0,0 +1,56 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('description', () { + bus.inProgress$.map((List events) => + events.whereType().isNotEmpty); + }, skip: 'should skip'); + + test('emit Follower Event', () { + const id = 'id'; + expect(bus.on(), emitsInAnyOrder([const FollowAppEvent('username')])); + bus.fire(const FollowAppEvent('username')); + }); + + test('emit Follower Events', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + EmptyEvent(), + const FollowAppEvent('username3'), + EmptyEvent(), + const FollowAppEvent('username2'), + EmptyEvent(), + ])); + + bus.fire(const FollowAppEvent('username')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username2')); + }); + + test('emit New Comment Event', () { + expect( + bus.on(), + emitsInOrder([ + const NewCommentEvent('comment #1'), + const NewCommentEvent('comment #2'), + ])); + + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const NewCommentEvent('comment #1')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const NewCommentEvent('comment #2')); + bus.fire(const FollowAppEvent('username3')); + }); +} diff --git a/sandbox/eventbus/test/timestamp_test.dart b/sandbox/eventbus/test/timestamp_test.dart new file mode 100644 index 0000000..a8dc0d0 --- /dev/null +++ b/sandbox/eventbus/test/timestamp_test.dart @@ -0,0 +1,36 @@ +import 'package:clock/clock.dart'; +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/test.dart'; +import 'dart:developer' as dev; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(maxHistoryLength: 3); + }); + + test('right timestamp', () { + final date = DateTime(2022, 9, 1); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + + for (final e in bus.history) { + e.timestamp.should.be(date); + dev.log(e.toString()); + } + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + ]); + }); + }); +} diff --git a/sandbox/eventbus/tool/coverage.sh b/sandbox/eventbus/tool/coverage.sh new file mode 100755 index 0000000..5313d46 --- /dev/null +++ b/sandbox/eventbus/tool/coverage.sh @@ -0,0 +1,13 @@ +#!/bin/sh +cd .. +# Generate `coverage/lcov.info` file +flutter test --coverage +# Generate HTML report +# Note: on macOS you need to have lcov installed on your system (`brew install lcov`) to use this: +genhtml coverage/lcov.info -o coverage/html +# Open the report +open coverage/html/index.html + + + + diff --git a/sandbox/mqueue/.github/workflows/action.yaml b/sandbox/mqueue/.github/workflows/action.yaml new file mode 100644 index 0000000..b1c9c11 --- /dev/null +++ b/sandbox/mqueue/.github/workflows/action.yaml @@ -0,0 +1,43 @@ +name: build + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v4 + + - name: 🎯 Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: 📦 Install Dependencies + run: dart pub get + + - name: ✨ Check Formatting + run: dart format --line-length 80 --set-exit-if-changed . + + - name: 🕵️ Analyze + run: dart analyze --fatal-infos --fatal-warnings . + + - name: 🧪 Run Tests + run: | + dart pub global activate coverage 1.2.0 + dart test --coverage=coverage && dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info + + - name: 📊 Check Code Coverage + uses: VeryGoodOpenSource/very_good_coverage@v2 + with: + path: ./coverage/lcov.info + min_coverage: 100 + + - name: 📈 Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/sandbox/mqueue/.gitignore b/sandbox/mqueue/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/sandbox/mqueue/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/sandbox/mqueue/CHANGELOG.md b/sandbox/mqueue/CHANGELOG.md new file mode 100644 index 0000000..70c0899 --- /dev/null +++ b/sandbox/mqueue/CHANGELOG.md @@ -0,0 +1,19 @@ +## 1.0.0 + +- Initial version of the package. + +## 1.0.1 + +- Fix documentation. (Image path) +- Update package description. +- Fix linter rules. (Type matching) +- Update `example` files. +- Remove `mocktail` dependency. + +## 1.0.2 + + - Update documentation. + +## 1.1.0 + + - `Deprecate` the `Consumer` mixin in favor of `ConsumerMixin`. ([#1](https://github.com/N-Razzouk/dart_mq/issues/1)) diff --git a/sandbox/mqueue/LICENSE b/sandbox/mqueue/LICENSE new file mode 100644 index 0000000..0c5e429 --- /dev/null +++ b/sandbox/mqueue/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Naif Razzouk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sandbox/mqueue/README.md b/sandbox/mqueue/README.md new file mode 100644 index 0000000..2563c78 --- /dev/null +++ b/sandbox/mqueue/README.md @@ -0,0 +1,165 @@ +# DartMQ: A Message Queue System for Dart and Flutter + + + +[![Pub](https://img.shields.io/pub/v/dart_mq.svg)](https://pub.dev/packages/dart_mq) +[![coverage](https://codecov.io/gh/N-Razzouk/dart_mq/graph/badge.svg)](https://app.codecov.io/gh/N-Razzouk/dart_mq) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +DartMQ is a Dart package that provides message queue functionality for sending messages between different components in your Dart and Flutter applications. It offers a simple and efficient way to implement message queues, making it easier to build robust and scalable applications. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Exchanges](#exchanges) +3. [Usage](#usage) +4. [Examples](#examples) +5. [Acknowledgment](#acknowledgment) + +### + +## Introduction + +In the development of complex applications, dependencies among components are almost inevitable. Often, different components within your application need to communicate with each other, leading to tight coupling between these elements. + +![Components](https://github.com/N-Razzouk/dart_mq/blob/master/assets/components.png?raw=true) + +### + +Message queues provide an effective means to decouple these components by enabling communication through messages. This decoupling strategy enhances the development of robust applications. + +![Components with MQ](https://github.com/N-Razzouk/dart_mq/blob/master/assets/components-mq.png?raw=true) + +### + +DartMQ employs the publish-subscribe pattern. **Producers** send messages, **Consumers** receive them, and **Queues** and **Exchanges** facilitate this communication. + +![Simple View](https://github.com/N-Razzouk/dart_mq/blob/master/assets/simple-view.png?raw=true) + +### + +Communication channels are called Exchanges. Exchanges receive messages from Producers, efficiently routing them to Queues for Consumer consumption. + +![Detailed View](https://github.com/N-Razzouk/dart_mq/blob/master/assets/detailed-view.png?raw=true) + +## Exchanges + +### DartMQ provides different types of Exchanges for different use cases. + +### + +- **Default Exchange**: Routes messages based on Queue names. + +![Default Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/default-exchange.png?raw=true) + +### + +- **Fanout Exchange**: Sends messages to all bound Queues. + +![Fanout Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/fanout-exchange.png?raw=true) + +### + +- **Direct Exchange**: Routes messages to Queues based on routing keys. + +![Direct Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/direct-exchange.png?raw=true) + +## Usage + +### Initialize an MQClient: + + + +```dart +import 'package:dart_mq/dart_mq.dart'; + +void main() { + // Initialize DartMQ + MQClient.initialize(); + + // Your application code here +} + +``` + +### Declare a Queue: + +```dart +MQClient.declareQueue('my_queue'); +``` + +> Note: Queues are idempotent, which means that if you declare a Queue multiple times, it will not create multiple Queues. Instead, it will return the existing Queue. + +### Create a Producer: + +```dart +class MyProducer with ProducerMixin { + void greet(String message) { + // Send a message to the queue + sendMessage( + routingKey: 'my_queue', + payload: message, + ); + } +} +``` + +> Note: `exchangeName` is optional. If you don't specify an exchange name, the message is sent to the default exchange. + +### Create a Consumer: + +```dart +class MyConsumer with ConsumerMixin { + void listenToQueue() { + // Subscribe to the queue and process incoming messages + subscribe( + queueId: 'my_queue', + callback: (message) { + // Handle incoming message + print('Received message: $message'); + }, + ) + } +} +``` + +### Putting it all together: + +```dart +void main() { + // Initialize DartMQ + MQClient.initialize(); + + // Declare a Queue + MQClient.declareQueue('my_queue'); + + // Create a Producer + final producer = MyProducer(); + + // Create a Consumer + final consumer = MyConsumer(); + + // Start listening + consumer.listenToQueue(); + + // Send a message + producer.greet('Hello World!'); + + // Your application code here + ... +} +``` + +## Examples + +- [Hello World](example/hello_world): A simple example that demonstrates how to send and receive messages using DartMQ. + +- [Message Filtering](example/message_filtering): An example that demonstrates how to multiple consumers can listen to the same queue and filter messages accordingly. + +- [Routing](example/routing): An example that demonstrates how to use Direct Exchanges to route messages to different queues based on the routing key. + +- [RPC (Remote Procedure Call)](example/rpc): An example that demonstrates how to send RPC requests and receive responses using DartMQ. + +## Acknowledgment + +- [RabbitMQ](https://www.rabbitmq.com/): This package is inspired by RabbitMQ, an open-source message-broker software that implements the Advanced Message Queuing Protocol (AMQP). diff --git a/sandbox/mqueue/analysis_options.yaml b/sandbox/mqueue/analysis_options.yaml new file mode 100644 index 0000000..662618b --- /dev/null +++ b/sandbox/mqueue/analysis_options.yaml @@ -0,0 +1,211 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +linter: + rules: + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - prefer_final_fields + - always_put_required_named_parameters_first + - avoid_init_to_null + - lines_longer_than_80_chars + - use_function_type_syntax_for_parameters + - avoid_relative_lib_imports + - avoid_shadowing_type_parameters + - avoid_equals_and_hash_code_on_mutable_classes + - unnecessary_brace_in_string_interps + - always_declare_return_types + - always_use_package_imports + - annotate_overrides + - avoid_bool_literals_in_conditional_expressions + - avoid_catching_errors + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + - avoid_final_parameters + - avoid_function_literals_in_foreach_calls + - avoid_js_rounded_ints + - avoid_multiple_declarations_per_line + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_print + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_setters_without_getters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - collection_methods_unrelated_type + - combinators_ordering + - comment_references + - conditional_uri_does_not_exist + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + - join_return_with_assignment + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_logic_in_create_state + - no_runtimeType_toString + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_null_aware_method_calls + - prefer_null_aware_operators + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + - require_trailing_commas + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_late + - unnecessary_library_directive + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_raw_strings + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unrelated_type_equality_checks + - use_build_context_synchronously + - use_colored_box + - use_enums + - use_full_hex_values_for_flutter_colors + - use_if_null_to_convert_nulls_to_bools + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + - use_to_and_as_if_applicable + - valid_regexps + - void_checks + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/sandbox/mqueue/assets/components-mq.png b/sandbox/mqueue/assets/components-mq.png new file mode 100644 index 0000000..c0bf845 Binary files /dev/null and b/sandbox/mqueue/assets/components-mq.png differ diff --git a/sandbox/mqueue/assets/components.png b/sandbox/mqueue/assets/components.png new file mode 100644 index 0000000..bfced0a Binary files /dev/null and b/sandbox/mqueue/assets/components.png differ diff --git a/sandbox/mqueue/assets/default-exchange.png b/sandbox/mqueue/assets/default-exchange.png new file mode 100644 index 0000000..1b721a2 Binary files /dev/null and b/sandbox/mqueue/assets/default-exchange.png differ diff --git a/sandbox/mqueue/assets/detailed-view.png b/sandbox/mqueue/assets/detailed-view.png new file mode 100644 index 0000000..2ce7eee Binary files /dev/null and b/sandbox/mqueue/assets/detailed-view.png differ diff --git a/sandbox/mqueue/assets/direct-exchange.png b/sandbox/mqueue/assets/direct-exchange.png new file mode 100644 index 0000000..f519dce Binary files /dev/null and b/sandbox/mqueue/assets/direct-exchange.png differ diff --git a/sandbox/mqueue/assets/fanout-exchange.png b/sandbox/mqueue/assets/fanout-exchange.png new file mode 100644 index 0000000..823a250 Binary files /dev/null and b/sandbox/mqueue/assets/fanout-exchange.png differ diff --git a/sandbox/mqueue/assets/simple-view.png b/sandbox/mqueue/assets/simple-view.png new file mode 100644 index 0000000..b24e6b8 Binary files /dev/null and b/sandbox/mqueue/assets/simple-view.png differ diff --git a/sandbox/mqueue/example/main.dart b/sandbox/mqueue/example/main.dart new file mode 100644 index 0000000..83f3e03 --- /dev/null +++ b/sandbox/mqueue/example/main.dart @@ -0,0 +1,16 @@ +import 'package:angel3_mq/mq.dart'; + +import 'receiver.dart'; +import 'sender.dart'; + +void main() async { + MQClient.initialize(); + + final sender = Sender(); + + final receiver = Receiver()..listenToGreeting(); + + await sender.sendGreeting(greeting: 'Hello, World!'); + + receiver.stopListening(); +} diff --git a/sandbox/mqueue/example/message_filtering/main.dart b/sandbox/mqueue/example/message_filtering/main.dart new file mode 100644 index 0000000..c5945a7 --- /dev/null +++ b/sandbox/mqueue/example/message_filtering/main.dart @@ -0,0 +1,27 @@ +import 'package:angel3_mq/mq.dart'; + +import 'task_manager.dart'; +import 'worker_one.dart'; +import 'worker_two.dart'; + +void main() async { + MQClient.initialize(); + + final workerOne = WorkerOne(); + + final workerTwo = WorkerTwo(); + + final taskManager = TaskManager(); + + workerOne.startListening(); + + workerTwo.startListening(); + + taskManager + ..sendTask(task: 'Hello..') + ..sendTask(task: 'Hello...') + ..sendTask(task: 'Hello....') + ..sendTask(task: 'Hello.') + ..sendTask(task: 'Hello.......') + ..sendTask(task: 'Hello..'); +} diff --git a/sandbox/mqueue/example/message_filtering/task_manager.dart b/sandbox/mqueue/example/message_filtering/task_manager.dart new file mode 100644 index 0000000..9c6aee9 --- /dev/null +++ b/sandbox/mqueue/example/message_filtering/task_manager.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/mq.dart'; + +final class TaskManager with ProducerMixin { + TaskManager() { + MQClient.instance.declareQueue('task_queue'); + } + + void sendTask({required String task}) => sendMessage( + payload: task, + routingKey: 'task_queue', + ); +} diff --git a/sandbox/mqueue/example/message_filtering/worker_one.dart b/sandbox/mqueue/example/message_filtering/worker_one.dart new file mode 100644 index 0000000..788c6bb --- /dev/null +++ b/sandbox/mqueue/example/message_filtering/worker_one.dart @@ -0,0 +1,22 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class WorkerOne with ConsumerMixin { + WorkerOne() { + MQClient.instance.declareQueue('task_queue'); + } + + void startListening() => subscribe( + queueId: 'task_queue', + filter: (Object messagePayload) => messagePayload + .toString() + .split('') + .where((String char) => char == '.') + .length + .isEven, + callback: (Message message) { + log('WorkerOne reacting to ${message.payload}'); + }, + ); +} diff --git a/sandbox/mqueue/example/message_filtering/worker_two.dart b/sandbox/mqueue/example/message_filtering/worker_two.dart new file mode 100644 index 0000000..a4cbd98 --- /dev/null +++ b/sandbox/mqueue/example/message_filtering/worker_two.dart @@ -0,0 +1,24 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class WorkerTwo with ConsumerMixin { + WorkerTwo() { + MQClient.instance.declareQueue('task_queue'); + } + + void startListening() => subscribe( + queueId: 'task_queue', + filter: (Object messagePayload) => + messagePayload + .toString() + .split('') + .where((String char) => char == '.') + .length % + 2 != + 0, + callback: (Message message) { + log('WorkerTwo reacting to ${message.payload}'); + }, + ); +} diff --git a/sandbox/mqueue/example/receiver.dart b/sandbox/mqueue/example/receiver.dart new file mode 100644 index 0000000..8b929a8 --- /dev/null +++ b/sandbox/mqueue/example/receiver.dart @@ -0,0 +1,18 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class Receiver with ConsumerMixin { + Receiver() { + MQClient.instance.declareQueue('hello'); + } + + void listenToGreeting() => subscribe( + queueId: 'hello', + callback: (Message message) { + log('Received: ${message.payload}'); + }, + ); + + void stopListening() => unsubscribe(queueId: 'hello'); +} diff --git a/sandbox/mqueue/example/routing/debug_logger.dart b/sandbox/mqueue/example/routing/debug_logger.dart new file mode 100644 index 0000000..e45bece --- /dev/null +++ b/sandbox/mqueue/example/routing/debug_logger.dart @@ -0,0 +1,39 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class DebugLogger with ConsumerMixin { + DebugLogger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('debug'); + } + + late final String _queueName; + + void startListening() { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'info', + ); + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'warning', + ); + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'error', + ); + subscribe( + queueId: _queueName, + callback: (Message message) { + log('Debug Logger recieved: ${message.payload}'); + }, + ); + } +} diff --git a/sandbox/mqueue/example/routing/logger.dart b/sandbox/mqueue/example/routing/logger.dart new file mode 100644 index 0000000..98f6e8c --- /dev/null +++ b/sandbox/mqueue/example/routing/logger.dart @@ -0,0 +1,21 @@ +import 'package:angel3_mq/mq.dart'; + +final class Logger with ProducerMixin { + Logger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + } + + Future log({ + required String level, + required String message, + }) async { + sendMessage( + payload: message, + exchangeName: 'logs', + routingKey: level, + ); + } +} diff --git a/sandbox/mqueue/example/routing/main.dart b/sandbox/mqueue/example/routing/main.dart new file mode 100644 index 0000000..4136032 --- /dev/null +++ b/sandbox/mqueue/example/routing/main.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/mq.dart'; + +import 'debug_logger.dart'; +import 'logger.dart'; +import 'production_logger.dart'; + +void main() async { + MQClient.initialize(); + + DebugLogger().startListening(); + + ProductionLogger().startListening(); + + final logger = Logger(); + + await logger.log( + level: 'info', + message: 'This is an info message', + ); + + await logger.log( + level: 'warning', + message: 'This is a warning message', + ); + + await logger.log( + level: 'error', + message: 'This is an error message', + ); +} diff --git a/sandbox/mqueue/example/routing/production_logger.dart b/sandbox/mqueue/example/routing/production_logger.dart new file mode 100644 index 0000000..e7c345a --- /dev/null +++ b/sandbox/mqueue/example/routing/production_logger.dart @@ -0,0 +1,29 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class ProductionLogger with ConsumerMixin { + ProductionLogger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('production'); + } + + late final String _queueName; + + void startListening() { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'error', + ); + subscribe( + queueId: _queueName, + callback: (Message message) { + log('Production Logger recieved: ${message.payload}'); + }, + ); + } +} diff --git a/sandbox/mqueue/example/rpc/main.dart b/sandbox/mqueue/example/rpc/main.dart new file mode 100644 index 0000000..ba05996 --- /dev/null +++ b/sandbox/mqueue/example/rpc/main.dart @@ -0,0 +1,19 @@ +import 'package:angel3_mq/mq.dart'; + +import 'service_one.dart'; +import 'service_two.dart'; + +void main() { + MQClient.initialize(); + + MQClient.instance.declareExchange( + exchangeName: 'ServiceRPC', + exchangeType: ExchangeType.direct, + ); + + final serviceOne = ServiceOne(); + + ServiceTwo().startListening(); + + serviceOne.requestFoo(); +} diff --git a/sandbox/mqueue/example/rpc/service_one.dart b/sandbox/mqueue/example/rpc/service_one.dart new file mode 100644 index 0000000..a2d4f97 --- /dev/null +++ b/sandbox/mqueue/example/rpc/service_one.dart @@ -0,0 +1,19 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +class ServiceOne with ProducerMixin { + Future requestFoo() async { + final res = await sendRPCMessage( + exchangeName: 'ServiceRPC', + routingKey: 'rpcBinding', + processId: 'foo', + args: {}, + ); + _handleFuture(res); + } + + void _handleFuture(String data) { + log('Service One received: $data\n'); + } +} diff --git a/sandbox/mqueue/example/rpc/service_two.dart b/sandbox/mqueue/example/rpc/service_two.dart new file mode 100644 index 0000000..20e4ba2 --- /dev/null +++ b/sandbox/mqueue/example/rpc/service_two.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +class ServiceTwo with ConsumerMixin { + ServiceTwo() { + MQClient.instance.declareExchange( + exchangeName: 'ServiceRPC', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('two'); + } + + late final String _queueName; + + Future startListening() async { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'ServiceRPC', + bindingKey: 'rpcBinding', + ); + subscribe( + queueId: _queueName, + callback: (Message message) async { + log('Service Two got message $message\n'); + if (message.headers['type'] == 'RPC') { + switch (message.headers['processId']) { + case 'foo': + final data = await foo(); + final Completer completer = + message.headers['completer'] ?? (throw Exception()); + completer.complete(data); + default: + } + } + }, + ); + } + + Future foo() async { + // log('Service Two bar\n'); + await Future.delayed(const Duration(seconds: 2)); + return 'Hello, world!'; + } +} diff --git a/sandbox/mqueue/example/sender.dart b/sandbox/mqueue/example/sender.dart new file mode 100644 index 0000000..6d9b842 --- /dev/null +++ b/sandbox/mqueue/example/sender.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/mq.dart'; + +final class Sender with ProducerMixin { + Sender() { + MQClient.instance.declareQueue('hello'); + } + + Future sendGreeting({required String greeting}) async => sendMessage( + routingKey: 'hello', + payload: greeting, + ); +} diff --git a/sandbox/mqueue/lib/mq.dart b/sandbox/mqueue/lib/mq.dart new file mode 100644 index 0000000..e3736c8 --- /dev/null +++ b/sandbox/mqueue/lib/mq.dart @@ -0,0 +1,11 @@ +/// Library definition. +library angel3_mq; + +/// Export files. +export 'src/consumer/consumer.dart'; +export 'src/consumer/consumer.mixin.dart'; +export 'src/core/constants/enums.dart'; +export 'src/message/message.dart'; +export 'src/mq/mq.dart'; +export 'src/producer/producer.dart'; +export 'src/producer/producer.mixin.dart'; diff --git a/sandbox/mqueue/lib/src/binding/binding.dart b/sandbox/mqueue/lib/src/binding/binding.dart new file mode 100644 index 0000000..c93d94a --- /dev/null +++ b/sandbox/mqueue/lib/src/binding/binding.dart @@ -0,0 +1,75 @@ +import 'package:angel3_mq/src/binding/binding.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a binding between a topic and its associated queues. +/// +/// The `Binding` class implements the [BindingInterface] interface and is +/// responsible for managing the association between a topic and its associated +/// queues. It allows the addition and removal of queues to the binding and the +/// publication of messages to all associated queues. +/// +/// Example: +/// ```dart +/// final binding = Binding('my_binding'); +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// +/// // Add queues to the binding. +/// binding.addQueue(queue1); +/// binding.addQueue(queue2); +/// +/// // Publish a message to all associated queues. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// binding.publishMessage(message); +/// +/// // Check if the binding has associated queues. +/// final hasQueues = binding.hasQueues(); // Returns true +/// ``` +final class Binding implements BindingInterface { + /// Creates a new binding with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the binding. + Binding(this.id); + + /// The unique identifier for the binding. + final String id; + + /// A list of associated queues. + final List _queues = []; + + @override + bool hasQueues() => _queues.isNotEmpty; + + @override + void addQueue(Queue queue) => _queues.add(queue); + + @override + void removeQueue(String queueId) => _queues.removeWhere( + (Queue queue) => queue.id == queueId && queue.hasListeners() + ? throw QueueHasSubscribersException(queueId) + : queue.id == queueId, + ); + + @override + void publishMessage(Message message) { + for (final queue in _queues) { + queue.enqueue(message); + } + } + + @override + void clear() { + for (final queue in _queues) { + if (queue.hasListeners()) { + throw QueueHasSubscribersException(queue.id); + } + } + _queues.clear(); + } +} diff --git a/sandbox/mqueue/lib/src/binding/binding.interface.dart b/sandbox/mqueue/lib/src/binding/binding.interface.dart new file mode 100644 index 0000000..18fd762 --- /dev/null +++ b/sandbox/mqueue/lib/src/binding/binding.interface.dart @@ -0,0 +1,44 @@ +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// An abstract interface class defining the contract for managing bindings. +/// +/// The `BindingInterface` abstract interface class defines a contract for +/// classes that are responsible for managing bindings between topics and +/// queues. Implementing classes must provide functionality for adding and +/// removing queues from the binding, publishing messages to the associated +/// queues, and checking if the binding has queues. +/// +/// Example: +/// ```dart +/// class MyBinding implements BindingInterface { +/// // Custom implementation of the binding interface methods. +/// } +/// ``` +abstract interface class BindingInterface { + /// Checks if the binding has associated queues. + /// + /// Returns `true` if the binding has one or more associated queues; + /// otherwise, `false`. + bool hasQueues(); + + /// Adds a queue to the binding. + /// + /// The [queue] parameter represents the queue to be associated with the + /// binding. + void addQueue(Queue queue); + + /// Removes a queue from the binding based on its ID. + /// + /// The [queueId] parameter represents the ID of the queue to be removed. + void removeQueue(String queueId); + + /// Publishes a message to all associated queues in the binding. + /// + /// The [message] parameter represents the message to be published to the + /// queues. + void publishMessage(Message message); + + /// Removes all queues from the binding. + void clear(); +} diff --git a/sandbox/mqueue/lib/src/consumer/consumer.dart b/sandbox/mqueue/lib/src/consumer/consumer.dart new file mode 100644 index 0000000..8d42065 --- /dev/null +++ b/sandbox/mqueue/lib/src/consumer/consumer.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/consumer/consumer.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; + +/// A mixin implementing the `ConsumerInterface` for message consumption. +/// +/// The `Consumer` mixin provides a concrete implementation of the +/// `ConsumerInterface`for message consumption. It allows classes to easily +/// consume messages from specific queues by subscribing to them, handling +/// received messages, and managing subscriptions. +/// +/// Example: +/// ```dart +/// class MyMessageConsumer with Consumer { +/// // Custom implementation of the message consumer. +/// } +/// ``` +@Deprecated('Please use `ConsumerMixin` instead. ' + 'This will be removed in v2.0.0') +mixin Consumer implements ConsumerInterface { + /// A registry of active message subscriptions. + final Registrar> _subscriptions = + Registrar>(); + + @override + Message? getLatestMessage(String queueId) => + MQClient.instance.getLatestMessage(queueId); + + @override + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + try { + final messageStream = MQClient.instance.fetchQueue(queueId); + + final sub = filter != null + ? messageStream.listen((Message message) { + if (filter(message.payload)) { + callback(message); + } + }) + : messageStream.listen(callback); + + _subscriptions.register(queueId, sub); + } on IdAlreadyRegisteredException catch (_) { + throw ConsumerAlreadySubscribedException( + consumer: runtimeType.toString(), + queue: queueId, + ); + } + } + + @override + void unsubscribe({required String queueId}) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + } + + @override + void pauseSubscription(String queueId) => _subscriptions.get(queueId).pause(); + + @override + void resumeSubscription(String queueId) => + _subscriptions.get(queueId).resume(); + + @override + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + subscribe( + queueId: queueId, + callback: callback, + filter: filter, + ); + } + + @override + void clearSubscriptions() { + for (final StreamSubscription sub in _subscriptions.getAll()) { + sub.cancel(); + } + + _subscriptions.clear(); + } +} diff --git a/sandbox/mqueue/lib/src/consumer/consumer.interface.dart b/sandbox/mqueue/lib/src/consumer/consumer.interface.dart new file mode 100644 index 0000000..6890099 --- /dev/null +++ b/sandbox/mqueue/lib/src/consumer/consumer.interface.dart @@ -0,0 +1,74 @@ +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message consumer. +/// +/// The `ConsumerInterface` abstract interface class defines a contract for +/// classes that implement a message consumer. Implementing classes must +/// provide methods for subscribing and unsubscribing from queues, pausing and +/// resuming subscriptions, updating subscriptions, retrieving the +/// latest message from a queue, and clearing all subscriptions. +/// +/// Example: +/// ```dart +/// class MyConsumer implements ConsumerInterface { +/// // Custom implementation of the message consumer. +/// } +/// ``` +abstract interface class ConsumerInterface { + /// Subscribes to a queue to receive messages. + /// + /// The [queueId] parameter represents the ID of the queue to subscribe to. + /// The [callback] parameter is a function that will be invoked for each + /// received message. + /// The [filter] parameter is an optional function that can be used to filter + /// messages based on custom criteria. + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }); + + /// Unsubscribes from a previously subscribed queue. + /// + /// The [queueId] parameter represents the ID of the queue to unsubscribe + /// from. + void unsubscribe({required String queueId}); + + /// Pauses message subscription for a specified queue. + /// + /// The [queueId] parameter represents the ID of the queue to pause the + /// subscription. + void pauseSubscription(String queueId); + + /// Resumes a paused subscription for a specified queue. + /// + /// The [queueId] parameter represents the ID of the queue to resume the + /// subscription. + void resumeSubscription(String queueId); + + /// Updates an existing subscription with a new callback and/or filter. + /// + /// The [queueId] parameter represents the ID of the queue to update the + /// subscription. + /// The [callback] parameter is a new function that will be invoked for each + /// received message. + /// The [filter] parameter is an optional new filter function for message + /// filtering. + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }); + + /// Retrieves the latest message from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch the latest + /// message from. + /// + /// Returns the latest message from the specified queue or `null` if the queue + /// is empty. + Message? getLatestMessage(String queueId); + + /// Clears all active subscriptions, unsubscribing from all queues. + void clearSubscriptions(); +} diff --git a/sandbox/mqueue/lib/src/consumer/consumer.mixin.dart b/sandbox/mqueue/lib/src/consumer/consumer.mixin.dart new file mode 100644 index 0000000..b3bc347 --- /dev/null +++ b/sandbox/mqueue/lib/src/consumer/consumer.mixin.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/consumer/consumer.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; + +/// A mixin implementing the `ConsumerInterface` for message consumption. +/// +/// The `ConsumerMixin` mixin provides a concrete implementation of the +/// `ConsumerInterface`for message consumption. It allows classes to easily +/// consume messages from specific queues by subscribing to them, handling +/// received messages, and managing subscriptions. +/// +/// Example: +/// ```dart +/// class MyMessageConsumer with ConsumerMixin { +/// // Custom implementation of the message consumer. +/// } +/// ``` +mixin ConsumerMixin implements ConsumerInterface { + /// A registry of active message subscriptions. + final Registrar> _subscriptions = + Registrar>(); + + @override + Message? getLatestMessage(String queueId) => + MQClient.instance.getLatestMessage(queueId); + + @override + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + try { + final messageStream = MQClient.instance.fetchQueue(queueId); + + final sub = filter != null + ? messageStream.listen((Message message) { + if (filter(message.payload)) { + callback(message); + } + }) + : messageStream.listen(callback); + + _subscriptions.register(queueId, sub); + } on IdAlreadyRegisteredException catch (_) { + throw ConsumerAlreadySubscribedException( + consumer: runtimeType.toString(), + queue: queueId, + ); + } + } + + @override + void unsubscribe({required String queueId}) => _subscriptions + ..get(queueId).cancel() + ..unregister(queueId); + + @override + void pauseSubscription(String queueId) => _subscriptions.get(queueId).pause(); + + @override + void resumeSubscription(String queueId) => + _subscriptions.get(queueId).resume(); + + @override + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + subscribe( + queueId: queueId, + callback: callback, + filter: filter, + ); + } + + @override + void clearSubscriptions() { + for (final StreamSubscription sub in _subscriptions.getAll()) { + sub.cancel(); + } + + _subscriptions.clear(); + } +} diff --git a/sandbox/mqueue/lib/src/core/constants/enums.dart b/sandbox/mqueue/lib/src/core/constants/enums.dart new file mode 100644 index 0000000..582d02c --- /dev/null +++ b/sandbox/mqueue/lib/src/core/constants/enums.dart @@ -0,0 +1,22 @@ +/// An enumeration representing different types of message exchanges. +/// +/// The [ExchangeType] enum defines various types of message exchanges that are +/// commonly used in messaging systems. Each type represents a specific behavior +/// for distributing messages to multiple queues or consumers. +/// +/// - `direct`: A direct exchange routes messages to queues based on a specified +/// routing key. +/// - `base`: The default exchange (unnamed) routes messages to queues using +/// their names. +/// - `fanout`: A fanout exchange routes messages to all connected queues, +/// ignoring routing keys. +enum ExchangeType { + /// Represents a direct message exchange. + direct, + + /// Represents the default exchange (unnamed). + base, + + /// Represents a fanout message exchange. + fanout, +} diff --git a/sandbox/mqueue/lib/src/core/constants/error_strings.dart b/sandbox/mqueue/lib/src/core/constants/error_strings.dart new file mode 100644 index 0000000..0ea4dc5 --- /dev/null +++ b/sandbox/mqueue/lib/src/core/constants/error_strings.dart @@ -0,0 +1,99 @@ +/// A utility class providing exception-related error messages. +/// +/// The `ExceptionStrings` class defines static methods that generate error +/// messages for various exception scenarios. These messages can be used to +/// provide descriptive error information in exception handling and debugging. +class ExceptionStrings { + /// Generates an error message when MQClient is not initialized. + /// + /// This message is used when attempting to use the MQClient before it has + /// been properly initialized using the `MQClient.initialize()` method. + static String mqClientNotInitialized() => + 'MQClient is not initialized. Please make sure to call ' + 'MQClient.initialize() first.'; + + /// Generates an error message for a Queue that is not registered. + /// + /// The [queueId] parameter represents the name of the unregistered queue. + static String queueNotRegistered(String queueId) => + 'Queue: $queueId is not registered.'; + + /// Generates an error message for a queue with active subscribers. + /// + /// The [queueId] parameter represents the ID of the queue with active + /// subscribers. + static String queueHasSubscribers(String queueId) => + 'Queue: $queueId has subscribers.'; + + /// Generates an error message for a queue with no name. + /// + /// This message is used when the name of the queue is not provided and is + /// null. + static String queueIdNull() => "Queue name can't be null."; + + /// Generates an error message for a required routing key. + /// + /// This message is used when a routing key is required for a specific + /// operation but is not provided. + static String routingKeyRequired() => 'Routing key is required.'; + + /// Generates an error message for a non-existent binding key. + /// + /// The [bindingKey] parameter represents the non-existent binding key. + static String bindingKeyNotFound(String bindingKey) => + 'The binding key "$bindingKey" was not found.'; + + /// Generates an error message for a missing binding key. + /// + /// This message is used when a binding operation expects a binding key to + static String bindingKeyRequired() => 'Binding key is required.'; + + /// Generates an error message for an exchange that is not registered. + /// + /// The [exchangeName] parameter represents the name of the unregistered + /// exchange. + static String exchangeNotRegistered(String exchangeName) => + 'Exchange: $exchangeName is not registered.'; + + /// Generates an error message for invalid exchange type. + static String invalidExchangeType() => 'Exchange type is invalid.'; + + /// Generates an error message for a consumer that is not subscribed to a + /// queue. + /// + /// The [consumerId] parameter represents the ID of the consumer. + /// The [queue] parameter represents the name of the queue. + static String consumerNotSubscribed(String consumerId, String queue) => + 'The consumer "$consumerId" is not subscribed to the queue "$queue".'; + + /// Generates an error message for a consumer that is already subscribed to + /// a queue. + /// + /// The [consumerId] parameter represents the ID of the consumer. + /// The [queue] parameter represents the name of the queue. + static String consumerAlreadySubscribed(String consumerId, String queue) => + 'The consumer "$consumerId" is already subscribed to the queue "$queue".'; + + /// Generates an error message for a consumer that is not registered. + /// + /// The [consumerId] parameter represents the ID of the consumer. + static String consumerNotRegistered(String consumerId) => + 'The consumer "$consumerId" is not registered.'; + + /// Generates an error message for a consumer that has active subscriptions. + /// + /// The [consumerId] parameter represents the ID of the consumer. + static String consumerHasSubscriptions(String consumerId) => + 'The consumer "$consumerId" has active subscriptions.'; + + /// Generates an error message for an ID that is already registered. + /// + /// The [id] parameter represents the ID that is already registered. + static String idAlreadyRegistered(String id) => + 'Id "$id" already registered.'; + + /// Generates an error message for an ID that is not registered. + /// + /// The [id] parameter represents the ID that is not registered. + static String idNotRegistered(String id) => 'Id "$id" not registered.'; +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/binding_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/binding_exceptions.dart new file mode 100644 index 0000000..898f5f1 --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/binding_exceptions.dart @@ -0,0 +1,42 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [BindingException] class represents a base exception related to +/// bindings. +/// +/// It is used to handle exceptions that may occur when working with bindings, +/// such as when a binding key is not found or when a binding key is required +/// but not provided. +/// +/// Subclasses of [BindingException] can provide more specific information about +/// the nature of the exception. +abstract base class BindingException implements Exception { + /// Creates a new [BindingException] with the specified error [message]. + BindingException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [BindingKeyNotFoundException] class represents an exception that occurs +/// when a binding key is not found. +/// +/// This exception is thrown when attempting to access a binding key that does +/// not exist in the context of bindings. +final class BindingKeyNotFoundException extends BindingException { + /// Creates a new [BindingKeyNotFoundException] instance. + BindingKeyNotFoundException(String key) + : super(ExceptionStrings.bindingKeyNotFound(key)); +} + +/// The [BindingKeyRequiredException] class represents an exception that occurs +/// when a binding key is required but not provided. +/// +/// This exception is thrown when a binding operation expects a binding key to +/// be provided, but it is missing or empty. +final class BindingKeyRequiredException extends BindingException { + /// Creates a new [BindingKeyRequiredException] instance. + BindingKeyRequiredException() : super(ExceptionStrings.bindingKeyRequired()); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/consumer_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/consumer_exceptions.dart new file mode 100644 index 0000000..9cd76cc --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/consumer_exceptions.dart @@ -0,0 +1,73 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [ConsumerException] class represents a base exception related to +/// consumers. +/// +/// It is used to handle exceptions that may occur when working with consumers, +/// such as when a consumer is not registered, is already subscribed to a queue, +/// is not subscribed to a queue when expected, or has active subscriptions. +/// +/// Subclasses of [ConsumerException] can provide more specific information +/// about the nature of the exception. +abstract base class ConsumerException implements Exception { + /// Creates a new [ConsumerException] with the specified error [message]. + ConsumerException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [ConsumerNotRegisteredException] class represents an exception that +/// occurs when a consumer is not registered. +/// +/// This exception is thrown when attempting to perform operations on a consumer +/// that has not been registered. +final class ConsumerNotRegisteredException extends ConsumerException { + /// Creates a new [ConsumerNotRegisteredException] instance with the + /// specified [consumer]. + ConsumerNotRegisteredException(String consumer) + : super(ExceptionStrings.consumerNotRegistered(consumer)); +} + +/// The [ConsumerAlreadySubscribedException] class represents an exception that +/// occurs when a consumer is already subscribed to a queue. +/// +/// This exception is thrown when attempting to subscribe a consumer to a queue +/// that it is already subscribed to. +final class ConsumerAlreadySubscribedException extends ConsumerException { + /// Creates a new [ConsumerAlreadySubscribedException] instance with the + /// specified [queue]. + ConsumerAlreadySubscribedException({ + required String consumer, + required String queue, + }) : super(ExceptionStrings.consumerAlreadySubscribed(consumer, queue)); +} + +/// The [ConsumerNotSubscribedException] class represents an exception that +/// occurs when a consumer is not subscribed to a queue when expected. +/// +/// This exception is thrown when an operation expects a consumer to be +/// subscribed to a queue, but the consumer is not. +final class ConsumerNotSubscribedException extends ConsumerException { + /// Creates a new [ConsumerNotSubscribedException] instance with the + /// specified [queue]. + ConsumerNotSubscribedException({ + required String consumer, + required String queue, + }) : super(ExceptionStrings.consumerNotSubscribed(consumer, queue)); +} + +/// The [ConsumerHasSubscriptionsException] class represents an exception that +/// occurs when a consumer has active subscriptions. +/// +/// This exception is thrown when an operation expects a consumer to have no +/// active subscriptions, but the consumer has active subscriptions. +final class ConsumerHasSubscriptionsException extends ConsumerException { + /// Creates a new [ConsumerHasSubscriptionsException] instance with the + /// specified [consumer]. + ConsumerHasSubscriptionsException(String consumer) + : super(ExceptionStrings.consumerHasSubscriptions(consumer)); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/exceptions.dart new file mode 100644 index 0000000..77c94ba --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/exceptions.dart @@ -0,0 +1,7 @@ +export 'binding_exceptions.dart'; +export 'consumer_exceptions.dart'; +export 'exchange_exceptions.dart'; +export 'mq_client_exceptions.dart'; +export 'queue_exceptions.dart'; +export 'registrar_exceptions.dart'; +export 'routing_key_exceptions.dart'; diff --git a/sandbox/mqueue/lib/src/core/exceptions/exchange_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/exchange_exceptions.dart new file mode 100644 index 0000000..eb9336f --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/exchange_exceptions.dart @@ -0,0 +1,44 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [ExchangeException] class represents a base exception related to +/// exchanges. +/// +/// It is used to handle exceptions that may occur when working with exchanges, +/// such as when an exchange is not registered or when an invalid exchange type +/// is encountered. +/// +/// Subclasses of [ExchangeException] can provide more specific information +/// about the nature of the exception. +abstract base class ExchangeException implements Exception { + /// Creates a new [ExchangeException] with the specified error [message]. + ExchangeException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [ExchangeNotRegisteredException] class represents an exception that +/// occurs when an exchange is not registered. +/// +/// This exception is thrown when attempting to perform operations on an +/// exchange that has not been registered. +final class ExchangeNotRegisteredException extends ExchangeException { + /// Creates a new [ExchangeNotRegisteredException] instance with the + /// specified [exchangeName]. + ExchangeNotRegisteredException(String exchangeName) + : super(ExceptionStrings.exchangeNotRegistered(exchangeName)); +} + +/// The [InvalidExchangeTypeException] class represents an exception that occurs +/// when an invalid exchange type is encountered. +/// +/// This exception is thrown when an operation encounters an exchange type that +/// is not recognized or supported. +final class InvalidExchangeTypeException extends ExchangeException { + /// Creates a new [InvalidExchangeTypeException] instance. + InvalidExchangeTypeException() + : super(ExceptionStrings.invalidExchangeType()); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart new file mode 100644 index 0000000..bc2c819 --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart @@ -0,0 +1,31 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [MQClientException] class represents a base exception related to the +/// MQClient. +/// +/// It is used to handle exceptions that may occur when working with the +/// MQClient, such as when the MQClient is not initialized. +/// +/// Subclasses of [MQClientException] can provide more specific information +/// about the nature of the exception. +abstract base class MQClientException implements Exception { + /// Creates a new [MQClientException] with the specified error [message]. + MQClientException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [MQClientNotInitializedException] class represents an exception that +/// occurs when the MQClient is not initialized. +/// +/// This exception is thrown when attempting to use the MQClient before it has +/// been properly initialized using the `MQClient.initialize()` method. +final class MQClientNotInitializedException extends MQClientException { + /// Creates a new [MQClientNotInitializedException] instance. + MQClientNotInitializedException() + : super(ExceptionStrings.mqClientNotInitialized()); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/queue_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/queue_exceptions.dart new file mode 100644 index 0000000..5a2947d --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/queue_exceptions.dart @@ -0,0 +1,54 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [QueueException] class represents a base exception related to queues. +/// +/// It is used to handle exceptions that may occur when working with queues, +/// such as when a queue is not registered or when there are subscribers to a +/// queue. +/// +/// Subclasses of [QueueException] can provide more specific information about +/// the nature of the exception. +abstract class QueueException implements Exception { + /// Creates a new [QueueException] with the specified error [message]. + QueueException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [QueueNotRegisteredException] class represents an exception that occurs +/// when a queue with a specific ID is not registered. +/// +/// This exception is thrown when attempting to perform an operation on an +/// unregistered queue. +final class QueueNotRegisteredException extends QueueException { + /// Creates a new [QueueNotRegisteredException] instance with the specified + /// [queueId]. + QueueNotRegisteredException(String queueId) + : super(ExceptionStrings.queueNotRegistered(queueId)); +} + +/// The [QueueHasSubscribersException] class represents an exception that occurs +/// when there are active subscribers to a queue. +/// +/// This exception is thrown when attempting to delete a queue that still has +/// subscribers listening to it. +final class QueueHasSubscribersException extends QueueException { + /// Creates a new [QueueHasSubscribersException] instance with the specified + /// [queueId]. + QueueHasSubscribersException(String queueId) + : super(ExceptionStrings.queueHasSubscribers(queueId)); +} + +/// The [QueueIdNullException] class represents an exception that occurs when +/// attempting to create a queue with a null name. +/// +/// This exception is thrown when the name of the queue is not provided and is +/// null. +final class QueueIdNullException extends QueueException { + /// Creates a new [QueueIdNullException] instance. + QueueIdNullException() : super(ExceptionStrings.queueIdNull()); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/registrar_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/registrar_exceptions.dart new file mode 100644 index 0000000..c5ef09b --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/registrar_exceptions.dart @@ -0,0 +1,43 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [RegistrarException] class represents a base exception related to +/// registrar operations. +/// +/// It is used to handle exceptions that may occur when working with registrar +/// objects, which are responsible for managing and registering items. +/// +/// Subclasses of [RegistrarException] can provide more specific information +/// about the nature of the exception. +abstract class RegistrarException implements Exception { + /// Creates a new [RegistrarException] with the specified error [message]. + RegistrarException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [IdAlreadyRegisteredException] class represents an exception that occurs +/// when attempting to register an ID that is already registered in a registrar. +/// +/// This exception is thrown when a duplicate ID is detected during the +/// registration process. +final class IdAlreadyRegisteredException extends RegistrarException { + /// Creates a new [IdAlreadyRegisteredException] instance with the specified + /// [id]. + IdAlreadyRegisteredException(String id) + : super(ExceptionStrings.idAlreadyRegistered(id)); +} + +/// The [IdNotRegisteredException] class represents an exception that occurs +/// when attempting to access an ID that is not registered in a registrar. +/// +/// This exception is thrown when an operation is performed on an unregistered +/// ID. +final class IdNotRegisteredException extends RegistrarException { + /// Creates a new [IdNotRegisteredException] instance with the specified [id]. + IdNotRegisteredException(String id) + : super(ExceptionStrings.idNotRegistered(id)); +} diff --git a/sandbox/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart b/sandbox/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart new file mode 100644 index 0000000..407b2f0 --- /dev/null +++ b/sandbox/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [RoutingKeyException] class represents a base exception related to +/// routing key operations. +/// +/// It is used to handle exceptions that may occur when working with routing +/// keys, which are used for message routing in message broker systems. +/// +/// Subclasses of [RoutingKeyException] can provide more specific information +/// about the nature of the exception. +abstract class RoutingKeyException implements Exception { + /// Creates a new [RoutingKeyException] with the specified error [message]. + RoutingKeyException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [RoutingKeyRequiredException] class represents an exception that occurs +/// when a routing key is required for a specific operation but is not provided. +/// +/// This exception is thrown when an operation expects a routing key to be +/// provided, but it is missing. +final class RoutingKeyRequiredException extends RoutingKeyException { + /// Creates a new [RoutingKeyRequiredException] instance. + RoutingKeyRequiredException() : super(ExceptionStrings.routingKeyRequired()); +} diff --git a/sandbox/mqueue/lib/src/core/registrar/simple_registrar.dart b/sandbox/mqueue/lib/src/core/registrar/simple_registrar.dart new file mode 100644 index 0000000..472d4c0 --- /dev/null +++ b/sandbox/mqueue/lib/src/core/registrar/simple_registrar.dart @@ -0,0 +1,100 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; + +/// A generic registrar for managing and storing objects by their unique +/// identifiers. +/// +/// The [Registrar] class allows you to register, get, unregister, and manage +/// objects associated with unique identifiers (IDs). It provides a way to store +/// and access objects in a key-value fashion. +/// +/// Example: +/// ```dart +/// final registrar = Registrar(); +/// +/// // Register objects with unique IDs. +/// registrar.register('user_1', 'Alice'); +/// registrar.register('user_2', 'Bob'); +/// +/// // Get an object by its ID. +/// final user1 = registrar.get('user_1'); // Returns 'Alice' +/// +/// // Check if an object with a specific ID exists. +/// final hasUser2 = registrar.has('user_2'); // Returns true +/// +/// // Unregister an object by its ID. +/// registrar.unregister('user_1'); +/// +/// // Check the number of registered objects. +/// final count = registrar.count; // Returns 1 +/// ``` +final class Registrar { + /// A map to store objects with their associated IDs. + final Map _registry = {}; + + /// Registers an object with a unique ID. + /// + /// The [id] parameter represents the unique identifier for the object. + /// The [value] parameter represents the object to be registered. + /// + /// If an object with the same ID already exists, an + /// [IdAlreadyRegisteredException] is thrown. + void register(String id, T value) { + if (_registry.containsKey(id)) { + throw IdAlreadyRegisteredException(id); + } + _registry[id] = value; + } + + /// Gets an object by its unique ID. + /// + /// The [id] parameter represents the unique identifier of the object to + /// retrieve. + /// + /// If no object with the specified ID is found, an [IdNotRegisteredException] + /// is thrown. + T get(String id) { + if (!_registry.containsKey(id)) { + throw IdNotRegisteredException(id); + } + return _registry[id]!; + } + + /// Retrieves a list of all registered objects. + List getAll() => _registry.values.toList(); + + /// Unregisters an object by its unique ID. + /// + /// The [id] parameter represents the unique identifier of the object to + /// unregister. + /// + /// If no object with the specified ID is found, an [IdNotRegisteredException] + /// is thrown. + void unregister(String id) { + if (!_registry.containsKey(id)) { + throw IdNotRegisteredException(id); + } + _registry.remove(id); + } + + /// Clears the registrar, removing all registered objects. + void clear() => _registry.clear(); + + /// Checks if an object with a specific ID is registered. + /// + /// The [id] parameter represents the unique identifier to check. + /// + /// Returns `true` if an object with the specified ID is registered; + /// otherwise, `false`. + bool has(String id) => _registry.containsKey(id); + + /// Returns the count of registered objects. + int get count => _registry.length; + + @override + String toString() { + return ''' +Registrar( +\t${_registry.entries.map((e) => '${e.key}: ${e.value}').join(',\n\t')} + )'''; + } +} diff --git a/sandbox/mqueue/lib/src/exchange/default_exchange.dart b/sandbox/mqueue/lib/src/exchange/default_exchange.dart new file mode 100644 index 0000000..4c20164 --- /dev/null +++ b/sandbox/mqueue/lib/src/exchange/default_exchange.dart @@ -0,0 +1,86 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing the default message exchange for message routing. +/// +/// The `DefaultExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing the default exchange. +/// It provides functionality for binding queues, forwarding messages based on +/// routing keys, and preventing unbinding from the default exchange. +/// +/// Example: +/// ```dart +/// final defaultExchange = DefaultExchange('default_exchange'); +/// +/// // Bind a queue to the default exchange. +/// final queue = Queue('my_queue'); +/// defaultExchange.bindQueue(queue: queue, bindingKey: 'my_routing_key'); +/// +/// // Forward a message to the default exchange using a routing key. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// defaultExchange.forwardMessage(message, routingKey: 'my_routing_key'); +/// ``` +final class DefaultExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the default exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the default + /// exchange. + DefaultExchange(super.id); + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : _registerAndGetBinding(bindingKey)) + ..addQueue(queue); + + Binding _registerAndGetBinding(String bindingKey) { + bindings.register(bindingKey, Binding(bindingKey)); + return bindings.get(bindingKey); + } + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) { + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : throw BindingKeyNotFoundException(bindingKey)) + .removeQueue(queueId); + + if (!bindings.get(bindingKey).hasQueues()) { + bindings.unregister(bindingKey); + } + } + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + (bindings.has( + routingKey ?? (throw RoutingKeyRequiredException()), + ) + ? bindings.get(routingKey) + : throw BindingKeyNotFoundException(routingKey)) + .publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/sandbox/mqueue/lib/src/exchange/direct_exchange.dart b/sandbox/mqueue/lib/src/exchange/direct_exchange.dart new file mode 100644 index 0000000..2560f29 --- /dev/null +++ b/sandbox/mqueue/lib/src/exchange/direct_exchange.dart @@ -0,0 +1,89 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a direct message exchange for message routing. +/// +/// The `DirectExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing a direct exchange. A +/// direct exchange routes messages to queues based on matching routing keys. +/// It provides functionality for binding queues, forwarding messages based on +/// routing keys, and unbinding queues from the direct exchange. +/// +/// Example: +/// ```dart +/// final directExchange = DirectExchange('my_direct_exchange'); +/// +/// // Bind queues to the direct exchange with different routing keys. +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// directExchange.bindQueue(queue: queue1, bindingKey: 'routing_key_1'); +/// directExchange.bindQueue(queue: queue2, bindingKey: 'routing_key_2'); +/// +/// // Forward a message with a matching routing key to the appropriate queue. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// directExchange.forwardMessage(message, routingKey: 'routing_key_1'); +/// ``` +final class DirectExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the direct exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the direct + /// exchange. + DirectExchange(super.id); + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : _registerAndGetBinding(bindingKey)) + .addQueue(queue); + + Binding _registerAndGetBinding(String bindingKey) { + bindings.register(bindingKey, Binding(bindingKey)); + return bindings.get(bindingKey); + } + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) { + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : throw BindingKeyNotFoundException(bindingKey)) + .removeQueue(queueId); + + if (!bindings.get(bindingKey).hasQueues()) { + bindings.unregister(bindingKey); + } + } + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + (bindings.has( + routingKey ?? (throw RoutingKeyRequiredException()), + ) + ? bindings.get(routingKey) + : throw BindingKeyNotFoundException(routingKey)) + .publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/sandbox/mqueue/lib/src/exchange/exchange.base.dart b/sandbox/mqueue/lib/src/exchange/exchange.base.dart new file mode 100644 index 0000000..18e8184 --- /dev/null +++ b/sandbox/mqueue/lib/src/exchange/exchange.base.dart @@ -0,0 +1,27 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; + +/// An abstract base class representing an exchange for message routing. +/// +/// The `BaseExchange` abstract base class defines the core functionality of a +/// message exchange for routing messages to specific queues or bindings. +/// +/// Example: +/// ```dart +/// class MyExchange extends BaseExchange { +/// // Custom implementation of the exchange. +/// } +/// ``` +abstract base class BaseExchange implements ExchangeInterface { + /// Creates a new exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the exchange. + BaseExchange(this.id); + + /// The unique identifier for the exchange. + final String id; + + /// A registrar for managing bindings associated with the exchange. + Registrar bindings = Registrar(); +} diff --git a/sandbox/mqueue/lib/src/exchange/exchange_interface.dart b/sandbox/mqueue/lib/src/exchange/exchange_interface.dart new file mode 100644 index 0000000..638ca72 --- /dev/null +++ b/sandbox/mqueue/lib/src/exchange/exchange_interface.dart @@ -0,0 +1,51 @@ +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// An abstract interface class defining the contract for managing exchanges. +/// +/// The `ExchangeInterface` defines a contract for classes that are responsible +/// for managing exchanges. Implementing classes must provide functionality for +/// binding queues to the exchange, unbinding queues from the exchange, +/// forwarding messages to queues or bindings, and removing queues from all +/// associated bindings. +/// +/// Example: +/// ```dart +/// class MyExchange implements ExchangeInterface { +/// // Custom implementation of the exchange. +/// } +/// ``` +abstract interface class ExchangeInterface { + /// Binds a queue to the exchange with a specific binding key. + /// + /// The [queue] parameter represents the queue to be bound to the exchange. + /// The [bindingKey] parameter represents the binding key for the queue. + void bindQueue({ + required Queue queue, + required String bindingKey, + }); + + /// Unbinds a queue from the exchange based on its ID and binding key. + /// + /// The [queueId] parameter represents the ID of the queue to be unbound. + /// The [bindingKey] parameter represents the binding key for the queue. + void unbindQueue({ + required String queueId, + required String bindingKey, + }); + + /// Forwards a message to queues or bindings based on the routing key. + /// + /// The [message] parameter represents the message to be forwarded. + /// The [routingKey] parameter represents the optional routing key to + /// determine the destination queues or bindings. + void forwardMessage({ + required Message message, + String? routingKey, + }); + + /// Removes a queue from all associated bindings. + /// + /// The [queueId] parameter represents the ID of the queue to be removed. + void deleteQueue(String queueId); +} diff --git a/sandbox/mqueue/lib/src/exchange/fanout_exchange.dart b/sandbox/mqueue/lib/src/exchange/fanout_exchange.dart new file mode 100644 index 0000000..a7a225e --- /dev/null +++ b/sandbox/mqueue/lib/src/exchange/fanout_exchange.dart @@ -0,0 +1,70 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a fanout message exchange for message routing. +/// +/// The `FanoutExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing a fanout exchange. +/// A fanout exchange routes messages to all associated queues without +/// considering routing keys. It provides functionality for binding queues, +/// forwarding messages to all associated queues, and unbinding queues +/// from the fanout exchange. +/// +/// Example: +/// ```dart +/// final fanoutExchange = FanoutExchange('my_fanout_exchange'); +/// +/// // Bind multiple queues to the fanout exchange. +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// fanoutExchange.bindQueue(queue: queue1, bindingKey: 'binding_key_1'); +/// fanoutExchange.bindQueue(queue: queue2, bindingKey: 'binding_key_2'); +/// +/// // Forward a message to all associated queues in the fanout exchange. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// fanoutExchange.forwardMessage(message); +/// ``` +final class FanoutExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the fanout exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the fanout + /// exchange. + FanoutExchange(super.id) { + bindings.register('', Binding('')); + } + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + bindings.get('').addQueue(queue); + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) => + bindings.get('').removeQueue(queueId); + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + bindings.get('').publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/sandbox/mqueue/lib/src/message/message.base.dart b/sandbox/mqueue/lib/src/message/message.base.dart new file mode 100644 index 0000000..57e101d --- /dev/null +++ b/sandbox/mqueue/lib/src/message/message.base.dart @@ -0,0 +1,43 @@ +/// Represents a base message with headers, payload, and an optional timestamp. +/// +/// A [BaseMessage] is a fundamental unit of data used in various messaging +/// systems. It typically contains metadata in the form of headers, the actual +/// payload, and an optional timestamp indicating when the message was created. +/// +/// The `headers` property is a map that can contain additional information +/// about the message, such as content type, sender, or any custom metadata. +/// +/// The `payload` property stores the main content of the message. It can be +/// of any type, allowing flexibility in the data that can be transmitted. +/// +/// The `timestamp` property, if provided, represents the time when the message +/// was created. It is formatted as an ISO 8601 string. +abstract class BaseMessage { + /// Creates a new `BaseMessage` with the specified headers, payload, and + /// timestamp. + /// + /// The [headers] parameter is a map that can contain additional information + /// about the message. It is optional and defaults to an empty map if not + /// provided. + /// + /// The [payload] parameter represents the main content of the message and is + /// required. + /// + /// The [timestamp] parameter is an optional ISO 8601 formatted timestamp + /// indicating when the message was created. If not provided, it will be + /// `null`. + BaseMessage( + Map? headers, + this.payload, + this.timestamp, + ) : headers = headers ?? {}; + + /// A map containing headers or metadata associated with the message. + final Map headers; + + /// The main content of the message. + final Object payload; + + /// An optional timestamp indicating when the message was created. + final String? timestamp; +} diff --git a/sandbox/mqueue/lib/src/message/message.dart b/sandbox/mqueue/lib/src/message/message.dart new file mode 100644 index 0000000..053ab13 --- /dev/null +++ b/sandbox/mqueue/lib/src/message/message.dart @@ -0,0 +1,86 @@ +import 'package:angel3_mq/src/message/message.base.dart'; +import 'package:uuid/uuid.dart'; + +/// Represents a message with headers, payload, and an optional timestamp. +/// +/// A [Message] is a specific type of message that extends the [BaseMessage] +/// class. +/// +/// Example: +/// ```dart +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// ``` +class Message extends BaseMessage { + /// Creates a new [Message] with the specified headers, payload, timestamp, and id. + /// + /// The [headers] parameter is a map that can contain additional information + /// about the message. It is optional and defaults to an empty map if not + /// provided. + /// + /// The [payload] parameter represents the main content of the message and is + /// required. + /// + /// The [timestamp] parameter is an optional ISO 8601 formatted timestamp + /// indicating when the message was created. If not provided, the current + /// timestamp will be used. + /// + /// The [id] parameter is an optional unique identifier for the message. + /// If not provided, a new UUID will be generated. + /// + /// Example: + /// ```dart + /// final message = Message( + /// headers: {'contentType': 'json', 'sender': 'Alice'}, + /// payload: {'text': 'Hello, World!'}, + /// timestamp: '2023-09-07T12:00:002', + /// id: '123e4567-e89b-12d3-a456-426614174000', + /// ); + /// ``` + Message({ + required Object payload, + Map? headers, + String? timestamp, + String? id, + }) : id = id ?? Uuid().v4(), + super( + headers, + payload, + timestamp ?? DateTime.now().toUtc().toIso8601String(), + ); + + /// A unique identifier for the message. + final String id; + + /// Returns a human-readable string representation of the message. + /// + /// Example: + /// ```dart + /// final message = Message( + /// headers: {'contentType': 'json', 'sender': 'Alice'}, + /// payload: {'text': 'Hello, World!'}, + /// timestamp: '2023-09-07T12:00:002', + /// ); + /// + /// print(message.toString()); + /// // Output: + /// // Message{ + /// // headers: {contentType: json, sender: Alice}, + /// // payload: {text: Hello, World!}, + /// // timestamp: 2023-09-07T12:00:002, + /// // } + /// ``` + @override + String toString() { + return ''' +Message{ + id: $id, + headers: $headers, + payload: $payload, + timestamp: $timestamp, +}'''; + } +} diff --git a/sandbox/mqueue/lib/src/mq/mq.base.dart b/sandbox/mqueue/lib/src/mq/mq.base.dart new file mode 100644 index 0000000..2acafd7 --- /dev/null +++ b/sandbox/mqueue/lib/src/mq/mq.base.dart @@ -0,0 +1,14 @@ +/// An abstract base class representing a message queue client. +/// +/// The `BaseMQClient` abstract base class defines the core functionality and +/// contract for implementing message queue clients. It serves as a foundation +/// for creating client implementations that interact with message queues for +/// sending and receiving messages. +/// +/// Example: +/// ```dart +/// class MyMQClient extends BaseMQClient { +/// // Custom implementation of the message queue client. +/// } +/// ``` +abstract class BaseMQClient {} diff --git a/sandbox/mqueue/lib/src/mq/mq.dart b/sandbox/mqueue/lib/src/mq/mq.dart new file mode 100644 index 0000000..1f48e6b --- /dev/null +++ b/sandbox/mqueue/lib/src/mq/mq.dart @@ -0,0 +1,256 @@ +import 'package:angel3_mq/src/core/constants/enums.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/exchange/default_exchange.dart'; +import 'package:angel3_mq/src/exchange/direct_exchange.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/fanout_exchange.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.base.dart'; +import 'package:angel3_mq/src/mq/mq.interface.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a message queue client with various messaging +/// functionalities. +/// +/// The `MQClient` class is an implementation of both the `BaseMQClient` class +/// and the `MQClientInterface` interface. It provides features for interacting +/// with message queues, including declaring and managing queues and exchanges, +/// sending and receiving messages, and binding/unbinding queues to/from exchanges. +/// +/// Example: +/// ```dart +/// // Initialize the message queue client. +/// MQClient.initialize(); +/// +/// // Declare a queue and an exchange. +/// final queueId = MQClient.instance.declareQueue(); +/// final exchangeName = 'my_direct_exchange'; +/// MQClient.instance.declareExchange( +/// exchangeName: exchangeName, +/// exchangeType: ExchangeType.direct, +/// ); +/// +/// // Bind the queue to the exchange. +/// MQClient.instance.bindQueue( +/// queueId: queueId, +/// exchangeName: exchangeName, +/// ); +/// +/// // Send a message to the exchange. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// MQClient.instance.sendMessage( +/// exchangeName: exchangeName, +/// message: message, +/// routingKey: queueId, +/// ); +/// +/// // Fetch messages from the queue. +/// final messageStream = MQClient.instance.fetchQueue(queueId); +/// messageStream.listen((message) { +/// print('Received message: $message'); +/// }); +/// ``` +class MQClient extends BaseMQClient implements MQClientInterface { + /// Private constructor to create the `MQClient` instance. + MQClient._internal() { + _exchanges.register('', DefaultExchange('')); + } + + /// Initializes the `MQClient` and creates a singleton instance. + /// + /// This method should be called before using the `MQClient`. + factory MQClient.initialize() => _instance ??= MQClient._internal(); + + /// Singleton instance of the `MQClient`. + static MQClient? _instance; + + /// Gets the singleton instance of the `MQClient`. + /// + /// Throws a [MQClientNotInitializedException] if the client has not been + /// initialized. + static MQClient get instance => + _instance ?? (throw MQClientNotInitializedException()); + + final Registrar _exchanges = Registrar(); + final Registrar _queues = Registrar(); + + @override + String declareQueue(String queueId) { + try { + _queues.register(queueId, Queue(queueId)); + + _exchanges.get('').bindQueue( + queue: _queues.get(queueId), + bindingKey: queueId, + ); + + return queueId; + } on IdAlreadyRegisteredException catch (_) { + return queueId; + } + } + + @override + void deleteQueue(String queueId) { + try { + final queue = _queues.get(queueId); + + if (queue.hasListeners()) { + throw QueueHasSubscribersException(queueId); + } else { + _deleteQueue(queueId); + } + } on IdNotRegisteredException catch (_) { + throw QueueNotRegisteredException(queueId); + } + } + + void _deleteQueue(String queueId) { + _queues.get(queueId).dispose(); + _exchanges.getAll().forEach( + (BaseExchange exchange) => exchange.deleteQueue(queueId), + ); + _queues.unregister(queueId); + } + + @override + Stream fetchQueue(String queueId) => _fetchQueue(queueId).dataStream; + + Queue _fetchQueue(String queueId) { + try { + return _queues.get(queueId); + } on IdNotRegisteredException catch (_) { + throw QueueNotRegisteredException(queueId); + } + } + + @override + List listQueues() => _queues + .getAll() + .map( + (Queue queue) => queue.id, + ) + .toList(); + + void deleteMessage(String queueId, Message message) { + try { + final queue = _fetchQueue(queueId); + queue.removeMessage(message); + } on QueueNotRegisteredException { + // Queue doesn't exist, so we can't delete the message + // We might want to log this or handle it in some way + } + } + + @override + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }) { + try { + _exchanges + .get(exchangeName ?? '') + .forwardMessage(routingKey: routingKey, message: message); + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName ?? ''); + } + } + + @override + Message? getLatestMessage(String queueId) => + _fetchQueue(queueId).latestMessage; + + @override + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + try { + final exchange = _exchanges.get(exchangeName); + switch (exchange) { + case DirectExchange _: + if (bindingKey == null) { + throw BindingKeyRequiredException(); + } + exchange.bindQueue( + queue: _fetchQueue(queueId), + bindingKey: bindingKey, + ); + case FanoutExchange _: + exchange.bindQueue( + queue: _fetchQueue(queueId), + bindingKey: '', + ); + default: + return; + } + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName); + } + } + + @override + void unbindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + try { + final exchange = _exchanges.get(exchangeName); + if (exchange.runtimeType == DirectExchange && bindingKey == null) { + throw BindingKeyRequiredException(); + } + exchange.unbindQueue( + queueId: queueId, + bindingKey: bindingKey ?? '', + ); + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName); + } + } + + @override + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }) { + try { + switch (exchangeType) { + case ExchangeType.direct: + _exchanges.register(exchangeName, DirectExchange(exchangeName)); + case ExchangeType.fanout: + _exchanges.register(exchangeName, FanoutExchange(exchangeName)); + case ExchangeType.base: + throw InvalidExchangeTypeException(); + } + } on IdAlreadyRegisteredException catch (_) { + return; + } + } + + @override + void deleteExchange(String exchangeName) { + try { + _exchanges.unregister(exchangeName); + } catch (_) { + return; + } + } + + @override + void close() { + _queues.getAll().forEach( + (Queue queue) => queue.dispose(), + ); + _queues.clear(); + _exchanges.clear(); + _instance = null; + } +} diff --git a/sandbox/mqueue/lib/src/mq/mq.interface.dart b/sandbox/mqueue/lib/src/mq/mq.interface.dart new file mode 100644 index 0000000..a1301c1 --- /dev/null +++ b/sandbox/mqueue/lib/src/mq/mq.interface.dart @@ -0,0 +1,115 @@ +import 'package:angel3_mq/src/core/constants/enums.dart'; +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message queue +/// client. +/// +/// The `MQClientInterface` abstract interface class defines a contract for +/// classes that implement a message queue client. Implementing classes must +/// provide methods for fetching messages from a queue, sending messages to an +/// exchange, declaring queues and exchanges, deleting queues and exchanges, +/// binding and unbinding queues from exchanges, and more. +/// +/// Example: +/// ```dart +/// class MyMQClient implements MQClientInterface { +/// // Custom implementation of the message queue client. +/// } +/// ``` +abstract interface class MQClientInterface { + /// Declares a queue in the message queue system. + /// + /// The [queueId] parameter represents the optional ID for the queue. + /// + /// Returns the ID of the declared queue. + String declareQueue(String queueId); + + /// Deletes a queue from the message queue system. + /// + /// The [queueId] parameter represents the ID of the queue to be deleted. + void deleteQueue(String queueId); + + /// Fetches messages from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch messages + /// from. + /// + /// Returns a stream of messages from the specified queue. + Stream fetchQueue(String queueId); + + /// Retrieves the list of queues. + /// + /// Returns a list of queue IDs. + List listQueues(); + + /// Sends a message to an exchange for routing to queues. + /// + /// The [exchangeName] parameter represents the name of the exchange to send + /// the message to. + /// The [message] parameter represents the message to be sent. + /// The [routingKey] parameter represents the optional routing key for message + /// routing within the exchange. + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }); + + /// Retrieves the latest message from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch the latest + /// message from. + /// + /// Returns the latest message from the specified queue or `null` if the queue + /// is empty. + Message? getLatestMessage(String queueId); + + /// Binds a queue to an exchange for message routing. + /// + /// The [queueId] parameter represents the ID of the queue to be bound. + /// The [exchangeName] parameter represents the name of the exchange to bind + /// to. + /// The [bindingKey] parameter represents the optional binding key for routing + /// messages to the queue within the exchange. + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }); + + /// Unbinds a queue from an exchange to stop message routing. + /// + /// The [queueId] parameter represents the ID of the queue to be unbound. + /// The [exchangeName] parameter represents the name of the exchange to unbind + /// from. + /// The [bindingKey] parameter represents the optional binding key previously + /// used for binding. + void unbindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }); + + /// Declares an exchange in the message queue system. + /// + /// The [exchangeName] parameter represents the name of the exchange to be + /// declared. + /// The [exchangeType] parameter represents the type of exchange (e.g., + /// direct, fanout). + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }); + + /// Deletes an exchange from the message queue system. + /// + /// The [exchangeName] parameter represents the name of the exchange to be + /// deleted. + void deleteExchange(String exchangeName); + + /// Closes the connection to the message queue system. + /// + /// This method should be called when the message queue client is no longer + /// needed. + void close(); +} diff --git a/sandbox/mqueue/lib/src/producer/producer.dart b/sandbox/mqueue/lib/src/producer/producer.dart new file mode 100644 index 0000000..2ec6bb5 --- /dev/null +++ b/sandbox/mqueue/lib/src/producer/producer.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; +import 'package:angel3_mq/src/producer/producer.interface.dart'; + +/// A mixin implementing the `ProducerInterface` for message production. +/// +/// The `Producer` mixin provides a concrete implementation of the +/// `ProducerInterface` for message production. It allows classes to easily send +/// messages to exchanges, send RPC (Remote Procedure Call) messages, and set a +/// callback for handling push notifications. +/// +/// Example: +/// ```dart +/// class MyMessageProducer with Producer { +/// // Custom implementation of the message producer. +/// } +/// ``` +@Deprecated('Please use `ProducerMixin` instead. ' + 'This will be removed in v2.0.0') +mixin Producer implements ProducerInterface { + /// A callback function for handling push notifications (received messages). + Function(Message message)? _callback; + + @override + void sendMessage({ + required Object payload, + String? exchangeName, + Map? headers, + String? routingKey, + String? timestamp, + }) { + final newMessage = Message( + payload: payload, + headers: headers, + timestamp: timestamp, + ); + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + _callback?.call(newMessage); + } + + @override + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + String? timestamp, + }) async { + final Completer completer = + mapper == null ? Completer() : Completer(); + + final newMessage = Message( + payload: 'RPC', + headers: { + 'type': 'RPC', + 'processId': processId, + 'args': args, + 'completer': completer, + }, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + if (mapper == null) { + _callback?.call(newMessage); + final res = await completer.future.then((value) => value); + return res; + } else { + _callback?.call(newMessage); + final rawData = await completer.future.then((value) => value); + final data = mapper(rawData); + return data; + } + } + + @override + void setPushCallback(Function(Message message) callback) => + _callback = callback; +} diff --git a/sandbox/mqueue/lib/src/producer/producer.interface.dart b/sandbox/mqueue/lib/src/producer/producer.interface.dart new file mode 100644 index 0000000..2fec00e --- /dev/null +++ b/sandbox/mqueue/lib/src/producer/producer.interface.dart @@ -0,0 +1,56 @@ +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message producer. +/// +/// The `ProducerInterface` abstract interface class defines a contract for +/// classes that implement a message producer. Implementing classes must provide +/// methods for sending messages to exchanges, sending RPC (Remote Procedure +/// Call) messages, and setting a callback for push notifications. +/// +/// Example: +/// ```dart +/// class MyProducer implements ProducerInterface { +/// // Custom implementation of the message producer. +/// } +/// ``` +abstract interface class ProducerInterface { + /// Sends a message to an exchange. + /// + /// The [payload] parameter represents the message payload to send. + /// The [exchangeName] parameter is the name of the exchange to send the + /// message to. + /// The [headers] parameter is an optional map of headers for the message. + /// The [routingKey] parameter is an optional routing key for the message. + void sendMessage({ + required Object payload, + required String exchangeName, + Map? headers, + String? routingKey, + }); + + /// Sends an RPC (Remote Procedure Call) message and awaits a response. + /// + /// The [processId] parameter is a unique identifier for the RPC request. + /// The [args] parameter is an optional map of arguments for the RPC request. + /// The [exchangeName] parameter is the name of the exchange for RPC + /// communication. + /// The [routingKey] parameter is an optional routing key for the RPC message. + /// The [mapper] parameter is an optional function to map the response + /// payload. + /// + /// Returns a future that completes with the response payload. + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + }); + + /// Sets a callback function to be called after every 'sendMessage` or + /// `sendRPCMessage`. + /// + /// The [callback] parameter is a function that will be invoked when a push + /// notification (message) is received. + void setPushCallback(Function(Message message) callback); +} diff --git a/sandbox/mqueue/lib/src/producer/producer.mixin.dart b/sandbox/mqueue/lib/src/producer/producer.mixin.dart new file mode 100644 index 0000000..5953fbb --- /dev/null +++ b/sandbox/mqueue/lib/src/producer/producer.mixin.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; +import 'package:angel3_mq/src/producer/producer.interface.dart'; + +/// A mixin implementing the `ProducerInterface` for message production. +/// +/// The `ProducerMixin` mixin provides a concrete implementation of the +/// `ProducerInterface` for message production. It allows classes to easily send +/// messages to exchanges, send RPC (Remote Procedure Call) messages, and set a +/// callback for handling push notifications. +/// +/// Example: +/// ```dart +/// class MyMessageProducer with ProducerMixin { +/// // Custom implementation of the message producer. +/// } +/// ``` +mixin ProducerMixin implements ProducerInterface { + /// A callback function for handling push notifications (received messages). + Function(Message message)? _callback; + + @override + void sendMessage({ + required Object payload, + String? exchangeName, + Map? headers, + String? routingKey, + String? timestamp, + }) { + final newMessage = Message( + payload: payload, + headers: headers, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + _callback?.call(newMessage); + } + + @override + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + String? timestamp, + }) async { + final Completer completer = + mapper == null ? Completer() : Completer(); + + final newMessage = Message( + payload: 'RPC', + headers: { + 'type': 'RPC', + 'processId': processId, + 'args': args, + 'completer': completer, + }, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + if (mapper == null) { + _callback?.call(newMessage); + final res = await completer.future.then((value) => value); + return res; + } else { + _callback?.call(newMessage); + final rawData = await completer.future.then((value) => value); + final data = mapper(rawData); + return data; + } + } + + @override + void setPushCallback(Function(Message message) callback) => + _callback = callback; +} diff --git a/sandbox/mqueue/lib/src/queue/data_stream.base.dart b/sandbox/mqueue/lib/src/queue/data_stream.base.dart new file mode 100644 index 0000000..5cb83f6 --- /dev/null +++ b/sandbox/mqueue/lib/src/queue/data_stream.base.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract base class for data streams that produce [Message] objects. +/// +/// The `BaseDataStream` class provides the foundation for creating data +/// streams that emit [Message] objects to their listeners. It includes a +/// [StreamController] to manage the stream of messages and methods to enqueue +/// messages and dispose of the stream when it's no longer needed. +/// +/// Example: +/// ```dart +/// class MyDataStream extends BaseDataStream { +/// // Custom methods and logic specific to your data stream can be added here. +/// } +/// ``` +abstract class BaseDataStream { + /// A [StreamController] for broadcasting [Message] objects to listeners. + final StreamController _data = StreamController.broadcast(); + + /// Returns a [Stream] of [Message] objects from this data stream. + Stream get dataStream => _data.stream; + + /// The latest [Message] enqueued in the data stream. + /// + /// This property keeps track of the most recently enqueued message. + Message? _latestMessage; + + /// Exposes the [_latestMessage] property. + /// + /// This getter returns the most recently enqueued message. + Message? get latestMessage => _latestMessage; + + /// Enqueues a [Message] to be emitted by the data stream. + /// + /// The [message] parameter represents the [Message] to enqueue, and it + /// becomes the latest message in the stream. + void enqueue(Message message) { + _latestMessage = message; + _data.add(message); + } + + /// Closes the data stream, freeing up resources. + /// + /// This method should be called when the data stream is no longer needed + /// to prevent resource leaks. + void dispose() => _data.close(); + + /// Checks if there are any active listeners on the data stream. + /// + /// Returns `true` if there are active listeners, and `false` otherwise. + bool hasListeners() => _data.hasListener; +} diff --git a/sandbox/mqueue/lib/src/queue/queue.dart b/sandbox/mqueue/lib/src/queue/queue.dart new file mode 100644 index 0000000..f03e00a --- /dev/null +++ b/sandbox/mqueue/lib/src/queue/queue.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/queue/data_stream.base.dart'; +import 'package:equatable/equatable.dart'; + +/// A class representing a queue for message streaming. +/// +/// The `Queue` class extends the [BaseDataStream] class and adds an +/// identifier, making it suitable for managing and streaming messages in a +/// queue-like fashion. +/// +/// Example: +/// ```dart +/// final myQueue = Queue('my_queue_id'); +/// +/// // Enqueue a message to the queue. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// myQueue.enqueue(message); +/// +/// // Check if the queue has active listeners. +/// final hasListeners = myQueue.hasListeners(); +/// ``` +class Queue extends BaseDataStream with EquatableMixin { + Queue(this.id); + final String id; + final StreamController _controller = + StreamController.broadcast(); + Message? _latestMessage; + + void addMessage(Message message) { + _latestMessage = message; + _controller.add(message); + } + + Stream get dataStream => _controller.stream; + + Message? get latestMessage => _latestMessage; + + bool hasListeners() => _controller.hasListener; + + void dispose() { + _controller.close(); + } + + // New method to remove a message + void removeMessage(Message message) { + if (_latestMessage == message) { + _latestMessage = null; + } + // Note: We can't remove past messages from the stream, + // but we can prevent this message from being processed again in the future. + } + + List get props => [id]; +} diff --git a/sandbox/mqueue/pubspec.yaml b/sandbox/mqueue/pubspec.yaml new file mode 100644 index 0000000..7985374 --- /dev/null +++ b/sandbox/mqueue/pubspec.yaml @@ -0,0 +1,18 @@ +name: angel3_mq +description: DartMQ is a message-queue system that facilitates communication between different components in the application. +repository: https://github.com/N-Razzouk/dart_mq +issue_tracker: https://github.com/N-Razzouk/dart_mq/issues +homepage: https://github.com/N-Razzouk/dart_mq +documentation: https://github.com/N-Razzouk/dart_mq +version: 1.1.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + equatable: ^2.0.5 + uuid: ^4.5.1 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.21.0 diff --git a/sandbox/mqueue/test/binding/binding_test.dart b/sandbox/mqueue/test/binding/binding_test.dart new file mode 100644 index 0000000..23d87c0 --- /dev/null +++ b/sandbox/mqueue/test/binding/binding_test.dart @@ -0,0 +1,97 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/queue_exceptions.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late Binding binding; + late Queue queue1; + late Queue queue2; + + setUp(() { + binding = Binding('my_binding'); + queue1 = Queue('queue_1'); + queue2 = Queue('queue_2'); + }); + + test('addQueue adds a queue to the binding', () { + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + }); + + test('removeQueue removes a queue from the binding', () { + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + + binding.removeQueue('queue_1'); + expect(binding.hasQueues(), isFalse); + }); + + test( + 'removeQueue throws QueueHasSubscribersException if queue has ' + 'subscribers', () { + final sub = queue1.dataStream.listen((_) {}); + + binding.addQueue(queue1); + + expect( + () => binding.removeQueue('queue_1'), + throwsA(isA()), + ); + + sub.cancel(); + }); + + test('publishMessage publishes a message to all associated queues', () { + binding + ..addQueue(queue1) + ..addQueue(queue2); + + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + binding.publishMessage(message); + + expect(queue1.latestMessage, equals(message)); + expect(queue2.latestMessage, equals(message)); + }); + + test('hasQueues returns true if the binding has associated queues', () { + expect(binding.hasQueues(), isFalse); + + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + }); + + test('clear clears all queues from the binding', () { + binding + ..addQueue(queue1) + ..addQueue(queue2); + + expect(binding.hasQueues(), isTrue); + + binding.clear(); + expect(binding.hasQueues(), isFalse); + }); + + test('clear throws QueueHasSubscribersException if a queue has subscribers', + () { + final sub = queue1.dataStream.listen((_) {}); + + binding + ..addQueue(queue1) + ..addQueue(queue2); + + expect(binding.hasQueues(), isTrue); + + expect(() => binding.clear(), throwsA(isA())); + + expect(binding.hasQueues(), isTrue); + + sub.cancel(); + }); +} diff --git a/sandbox/mqueue/test/consumer/consumer_test.dart b/sandbox/mqueue/test/consumer/consumer_test.dart new file mode 100644 index 0000000..f7fb078 --- /dev/null +++ b/sandbox/mqueue/test/consumer/consumer_test.dart @@ -0,0 +1,333 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +class MyMessageConsumer with ConsumerMixin { + // Custom implementation of the message consumer. +} + +void main() { + group('Consumer', () { + final consumer = MyMessageConsumer(); + setUpAll(() { + MQClient.initialize(); + + MQClient.instance.declareQueue('test-queue'); + }); + + test('subscribe should register a subscription and receive messages', + () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer.subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback was called with the expected messages + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + }); + + test('unsubscribe should cancel a subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Unsubscribe and ensure that the callback is not called + consumer.unsubscribe(queueId: queueId); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages.length, equals(1)); + }); + + test('pauseSubscription should pause a subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Pause the subscription and ensure that the callback is not called + consumer.pauseSubscription(queueId); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages.length, equals(1)); + }); + + test('resumeSubscription should resume a paused subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish a message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + // Pause and then resume the subscription and ensure that the callback is + // called. + consumer + ..pauseSubscription(queueId) + ..resumeSubscription(queueId); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.length, equals(2)); + }); + + test( + 'updateSubscription should update a subscription with a new callback ' + 'and filter', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + // Update the subscription with a new callback and filter + consumer.updateSubscription( + queueId: queueId, + callback: (message) { + if (message.payload == 'Message 2') { + callbackMessages.add(message); + } + }, + filter: (payload) => payload == 'Message 2', + ); + + // Publish another message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback is only called with 'Message 2' + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.contains(message3), isFalse); + expect(callbackMessages.length, equals(3)); + }); + + test('clearSubscriptions should clear all subscriptions', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + // Update the subscription with a new callback and filter + consumer.clearSubscriptions(); + + // Publish another message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback is only called on the first two messages. + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.contains(message3), isFalse); + expect(callbackMessages.length, equals(2)); + }); + + test('getLatestMessage should return the latest message from a queue', + () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (_) {}, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Get the latest message + final latestMessage = consumer.getLatestMessage(queueId); + + // Ensure that the latest message is 'Message 3' + expect(latestMessage, equals(message3)); + }); + + test( + 'subscribing to a queue that has already been subscribed to throws an ' + 'error.', () { + const queueId = 'test-queue'; + + consumer + ..clearSubscriptions() + ..subscribe(queueId: queueId, callback: (_) {}); + + expect( + () => consumer.subscribe(queueId: queueId, callback: (_) {}), + throwsA(isA()), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/binding_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/binding_exceptions_test.dart new file mode 100644 index 0000000..8d0e1bd --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/binding_exceptions_test.dart @@ -0,0 +1,24 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('BindingException', () { + test('BindingKeyNotFoundException', () { + final exception = BindingKeyNotFoundException('test-key'); + expect(exception.toString(), contains('BindingKeyNotFoundException')); + expect( + exception.toString(), + contains( + 'BindingKeyNotFoundException:' + ' The binding key "test-key" was not found.', + ), + ); + }); + + test('BindingKeyRequiredException', () { + final exception = BindingKeyRequiredException(); + expect(exception.toString(), contains('BindingKeyRequiredException')); + expect(exception.toString(), contains('Binding key is required')); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/consumer_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/consumer_exceptions_test.dart new file mode 100644 index 0000000..0e302d2 --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/consumer_exceptions_test.dart @@ -0,0 +1,60 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConsumerException', () { + test('ConsumerNotRegisteredException', () { + final exception = ConsumerNotRegisteredException('Alice'); + expect(exception.toString(), contains('ConsumerNotRegisteredException')); + expect( + exception.toString(), + contains('ConsumerNotRegisteredException: The consumer "Alice" is not ' + 'registered.'), + ); + }); + + test('ConsumerAlreadySubscribedException', () { + final exception = ConsumerAlreadySubscribedException( + consumer: 'NewsConsumer', + queue: 'NewsQueue', + ); + expect( + exception.toString(), + contains('ConsumerAlreadySubscribedException'), + ); + expect( + exception.toString(), + contains( + 'ConsumerAlreadySubscribedException: The consumer "NewsConsumer" ' + 'is already subscribed to the queue "NewsQueue".'), + ); + }); + + test('ConsumerNotSubscribedException', () { + final exception = ConsumerNotSubscribedException( + consumer: 'WeatherConsumer', + queue: 'WeatherQueue', + ); + expect(exception.toString(), contains('ConsumerNotSubscribedException')); + expect( + exception.toString(), + contains( + 'ConsumerNotSubscribedException: The consumer "WeatherConsumer" ' + 'is not subscribed to the queue "WeatherQueue".'), + ); + }); + + test('ConsumerHasSubscriptionsException', () { + final exception = ConsumerHasSubscriptionsException('Bob'); + expect( + exception.toString(), + contains('ConsumerHasSubscriptionsException'), + ); + expect( + exception.toString(), + contains('ConsumerHasSubscriptionsException: The consumer "Bob" has ' + 'active subscriptions.'), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/exchange_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/exchange_exceptions_test.dart new file mode 100644 index 0000000..da20214 --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/exchange_exceptions_test.dart @@ -0,0 +1,21 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ExchangeException', () { + test('ExchangeNotRegisteredException', () { + final exception = ExchangeNotRegisteredException('NewsExchange'); + expect(exception.toString(), contains('ExchangeNotRegisteredException')); + expect( + exception.toString(), + contains('Exchange: NewsExchange is not registered'), + ); + }); + + test('InvalidExchangeTypeException', () { + final exception = InvalidExchangeTypeException(); + expect(exception.toString(), contains('InvalidExchangeTypeException')); + expect(exception.toString(), contains('Exchange type is invalid.')); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/mq_client_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/mq_client_exceptions_test.dart new file mode 100644 index 0000000..d38a122 --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/mq_client_exceptions_test.dart @@ -0,0 +1,17 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('MQClientException', () { + test('MQClientNotInitializedException', () { + final exception = MQClientNotInitializedException(); + expect(exception.toString(), contains('MQClientNotInitializedException')); + expect( + exception.toString(), + contains('MQClientNotInitializedException: MQClient is not ' + 'initialized. Please make sure to call MQClient.initialize() ' + 'first.'), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/queue_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/queue_exceptions_test.dart new file mode 100644 index 0000000..a0be43a --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/queue_exceptions_test.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('QueueException', () { + test('QueueNotRegisteredException', () { + final exception = QueueNotRegisteredException('my_queue_id'); + expect(exception.toString(), contains('QueueNotRegisteredException')); + expect( + exception.toString(), + contains('Queue: my_queue_id is not registered'), + ); + }); + + test('QueueHasSubscribersException', () { + final exception = QueueHasSubscribersException('my_queue_id'); + expect(exception.toString(), contains('QueueHasSubscribersException')); + expect( + exception.toString(), + contains('Queue: my_queue_id has subscribers'), + ); + }); + + test('QueueIdNullException', () { + final exception = QueueIdNullException(); + expect(exception.toString(), contains('QueueIdNullException')); + expect(exception.toString(), contains("Queue name can't be null")); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/registrar_exceptions_test.dart b/sandbox/mqueue/test/core/exceptions/registrar_exceptions_test.dart new file mode 100644 index 0000000..8f1d3f9 --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/registrar_exceptions_test.dart @@ -0,0 +1,25 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('RegistrarException', () { + test('IdAlreadyRegisteredException', () { + final exception = IdAlreadyRegisteredException('my_id'); + expect(exception.toString(), contains('IdAlreadyRegisteredException')); + expect( + exception.toString(), + contains('IdAlreadyRegisteredException: Id ' + '"my_id" already registered'), + ); + }); + + test('IdNotRegisteredException', () { + final exception = IdNotRegisteredException('my_id'); + expect(exception.toString(), contains('IdNotRegisteredException')); + expect( + exception.toString(), + contains('IdNotRegisteredException: Id "my_id" not registered.'), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart b/sandbox/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart new file mode 100644 index 0000000..de8f389 --- /dev/null +++ b/sandbox/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('RoutingKeyException', () { + test('RoutingKeyRequiredException', () { + final exception = RoutingKeyRequiredException(); + expect(exception.toString(), contains('RoutingKeyRequiredException')); + expect(exception.toString(), contains('Routing key is required')); + }); + }); +} diff --git a/sandbox/mqueue/test/core/registrar/simple_registrar_test.dart b/sandbox/mqueue/test/core/registrar/simple_registrar_test.dart new file mode 100644 index 0000000..975d699 --- /dev/null +++ b/sandbox/mqueue/test/core/registrar/simple_registrar_test.dart @@ -0,0 +1,105 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:test/test.dart'; + +void main() { + late Registrar registrar; + + setUp(() { + registrar = Registrar(); + }); + + test('register and get objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + expect(registrar.get('user_1'), equals('Alice')); + expect(registrar.get('user_2'), equals('Bob')); + }); + + test('register throws IdAlreadyRegisteredException for duplicate IDs', () { + registrar.register('user_1', 'Alice'); + expect( + () => registrar.register('user_1', 'Another Alice'), + throwsA(const TypeMatcher()), + ); + }); + + test('get throws IdNotRegisteredException for unknown IDs', () { + expect( + () => registrar.get('unknown_id'), + throwsA(const TypeMatcher()), + ); + }); + + test('getAll returns a list of all registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + final allObjects = registrar.getAll(); + + expect(allObjects, contains('Alice')); + expect(allObjects, contains('Bob')); + }); + + test('unregister removes objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob') + ..unregister('user_1'); + + expect( + () => registrar.get('user_1'), + throwsA(const TypeMatcher()), + ); + expect(registrar.get('user_2'), equals('Bob')); + }); + + test('unregister throws IdNotRegisteredException for unknown IDs', () { + expect( + () => registrar.unregister('unknown_id'), + throwsA(const TypeMatcher()), + ); + }); + + test('clear removes all registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob') + ..clear(); + + expect(registrar.count, equals(0)); + }); + + test('has checks if an object is registered', () { + registrar.register('user_1', 'Alice'); + + expect(registrar.has('user_1'), isTrue); + expect(registrar.has('user_2'), isFalse); + }); + + test('count returns the number of registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + expect(registrar.count, equals(2)); + }); + + test('toString returns a formatted string representation of the registrar', + () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + const expectedString = ''' +Registrar( +\tuser_1: Alice, +\tuser_2: Bob + )'''; + + expect(registrar.toString(), equals(expectedString)); + }); +} diff --git a/sandbox/mqueue/test/exchange/default_exchange_test.dart b/sandbox/mqueue/test/exchange/default_exchange_test.dart new file mode 100644 index 0000000..547f9e1 --- /dev/null +++ b/sandbox/mqueue/test/exchange/default_exchange_test.dart @@ -0,0 +1,79 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/default_exchange.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late DefaultExchange defaultExchange; + late Queue queue; + late Message message; + + setUp(() { + defaultExchange = DefaultExchange('default_exchange'); + queue = Queue('my_queue'); + message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + }); + + test('bindQueue binds a queue to the default exchange with a binding key', + () { + defaultExchange.bindQueue(queue: queue, bindingKey: 'my_routing_key'); + expect(defaultExchange.bindings.has('my_routing_key'), isTrue); + }); + + test( + 'unbindQueue throws an exception when attempting to unbind from the ' + 'default exchange', () { + expect( + () => defaultExchange.unbindQueue( + queueId: 'my_queue_id', + bindingKey: 'my_routing_key', + ), + throwsA(isA()), + ); + }); + + test('unbindQueue unbinds a queue from the default exchange', () { + defaultExchange + ..bindQueue(queue: queue, bindingKey: 'my_routing_key') + ..unbindQueue( + queueId: queue.id, + bindingKey: 'my_routing_key', + ); + expect(defaultExchange.bindings.has('my_routing_key'), isFalse); + }); + + test( + 'forwardMessage forwards a message to the default exchange using a ' + 'routing key', () { + defaultExchange + ..bindQueue(queue: queue, bindingKey: 'my_routing_key') + ..forwardMessage(message: message, routingKey: 'my_routing_key'); + expect(queue.latestMessage, equals(message)); + }); + + test( + 'forwardMessage throws BindingKeyNotFoundException when routing key is ' + 'not found', () { + expect( + () => defaultExchange.forwardMessage( + message: message, + routingKey: 'non_existent_routing_key', + ), + throwsA(isA()), + ); + }); + + test( + 'forwardMessage throws RoutingKeyRequiredException when routing key is ' + 'null', () { + expect( + () => defaultExchange.forwardMessage(message: message), + throwsA(isA()), + ); + }); +} diff --git a/sandbox/mqueue/test/exchange/direct_exchange_test.dart b/sandbox/mqueue/test/exchange/direct_exchange_test.dart new file mode 100644 index 0000000..0f0de29 --- /dev/null +++ b/sandbox/mqueue/test/exchange/direct_exchange_test.dart @@ -0,0 +1,88 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/direct_exchange.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late DirectExchange directExchange; + late Queue queue1; + late Queue queue2; + late Message message; + + setUp(() { + directExchange = DirectExchange('my_direct_exchange'); + queue1 = Queue('queue_1'); + queue2 = Queue('queue_2'); + message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + }); + + test('bindQueue binds a queue to the direct exchange with a binding key', () { + directExchange.bindQueue(queue: queue1, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isTrue); + }); + + test( + 'bindQueue binds a queue to the direct exchange with a binding key that ' + 'already exists.', () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..bindQueue(queue: queue2, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isTrue); + }); + + test( + 'unbindQueue unbinds a queue from the direct exchange with a binding key', + () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..unbindQueue(queueId: queue1.id, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isFalse); + }); + + test( + 'forwardMessage forwards a message to the direct exchange using a ' + 'routing key', () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..forwardMessage(message: message, routingKey: 'routing_key_1'); + expect(queue1.latestMessage, equals(message)); + }); + + test( + 'forwardMessage throws BindingKeyNotFoundException when routing key is ' + 'not found', () { + expect( + () => directExchange.forwardMessage( + message: message, + routingKey: 'non_existent_routing_key', + ), + throwsA(isA()), + ); + }); + + test( + 'forwardMessage throws RoutingKeyRequiredException when routing key is ' + 'null', () { + expect( + () => directExchange.forwardMessage(message: message), + throwsA(isA()), + ); + }); + + test( + 'unbindQueue throws BindingKeyNotFoundException when attempting to ' + 'unbind with an invalid binding key', () { + expect( + () => directExchange.unbindQueue( + queueId: 'queue_id', + bindingKey: 'invalid_binding_key', + ), + throwsA(isA()), + ); + }); +} diff --git a/sandbox/mqueue/test/exchange/fanout_exchange_test.dart b/sandbox/mqueue/test/exchange/fanout_exchange_test.dart new file mode 100644 index 0000000..3332216 --- /dev/null +++ b/sandbox/mqueue/test/exchange/fanout_exchange_test.dart @@ -0,0 +1,69 @@ +import 'package:angel3_mq/src/exchange/fanout_exchange.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + group('FanoutExchange', () { + test('bindQueue should add a queue to the exchange', () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2'); + + expect(fanoutExchange.bindings.get('').hasQueues(), isTrue); + }); + + test('unbindQueue should remove a queue from the exchange', () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2') + ..unbindQueue(queueId: 'queue_1', bindingKey: 'binding_key_1') + ..unbindQueue(queueId: 'queue_2', bindingKey: 'binding_key_2'); + + expect(fanoutExchange.bindings.get('').hasQueues(), isFalse); + }); + + test('forwardMessage should forward a message to all associated queues', + () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2'); + + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + fanoutExchange.forwardMessage(message: message); + + expect(queue1.latestMessage, equals(message)); + expect(queue2.latestMessage, equals(message)); + }); + + test('removeQueue removes a queue from all bindings', () { + final queue1 = Queue('queue_1'); + + final fanoutExchange = FanoutExchange('my_fanout_exchange') + ..bindQueue(queue: queue1, bindingKey: '') + ..unbindQueue( + queueId: queue1.id, + bindingKey: '', + ); + + expect(fanoutExchange.bindings.get('').hasQueues(), isFalse); + }); + }); +} diff --git a/sandbox/mqueue/test/message/message.base_test.dart b/sandbox/mqueue/test/message/message.base_test.dart new file mode 100644 index 0000000..86cc658 --- /dev/null +++ b/sandbox/mqueue/test/message/message.base_test.dart @@ -0,0 +1,59 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:test/test.dart'; + +void main() { + group('BaseMessage', () { + test('Creating a BaseMessage', () { + // Arrange + final headers = {'content-type': 'text/plain'}; + const payload = 'Hello, World!'; + const timestamp = '2023-09-07T12:00:002'; + + // Act + final baseMessage = + Message(payload: payload, headers: headers, timestamp: timestamp); + + // Assert + expect(baseMessage.headers, equals(headers)); + expect(baseMessage.payload, equals(payload)); + expect(baseMessage.timestamp, equals(timestamp)); + }); + + test('Creating a BaseMessage without headers and timestamp', () { + // Arrange + const payload = 'Hello, World!'; + + // Act + final baseMessage = Message( + payload: payload, + ); + + // Assert + expect(baseMessage.headers, isEmpty); + expect(baseMessage.payload, equals(payload)); + expect(baseMessage.timestamp, isNotNull); + }); + + test('toString function.', () { + // Arrange + final headers = {'content-type': 'text/plain'}; + const payload = 'Hello, World!'; + const timestamp = '2023-09-07T12:00:002'; + + // Act + final baseMessage = + Message(payload: payload, headers: headers, timestamp: timestamp); + + // Assert + expect( + baseMessage.toString(), + equals(''' +Message{ + headers: $headers, + payload: $payload, + timestamp: $timestamp, + }'''), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/mq/mq_test.dart b/sandbox/mqueue/test/mq/mq_test.dart new file mode 100644 index 0000000..0c81eea --- /dev/null +++ b/sandbox/mqueue/test/mq/mq_test.dart @@ -0,0 +1,342 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('Initialization', () { + test( + 'MQClient instance should throw MQClientNotInitializedException if ' + 'not initialized', () { + expect( + () => MQClient.instance, + throwsA(isA()), + ); + }); + + test('MQClient initialize should create a singleton instance', () { + MQClient.initialize(); + final initializedInstance = MQClient.instance; + expect(initializedInstance, isA()); + expect(MQClient.instance, equals(initializedInstance)); + }); + }); + + group('Queue Operations', () { + setUpAll(MQClient.initialize); + + test('listQueues should return a list of all registered queues', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queues = MQClient.instance.listQueues(); + expect(queues, isA>()); + expect(queues, contains(queueId)); + MQClient.instance.deleteQueue(queueId); + }); + test( + 'declareQueue should declare a new queue and bind it to the default ' + 'exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queue = MQClient.instance.fetchQueue(queueId); + expect(queue, isNotNull); + MQClient.instance.deleteQueue(queueId); + }); + + test('declareQueue should declare a new queue with the specified name', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queue = MQClient.instance.fetchQueue(queueId); + expect(queue, isNotNull); + expect(MQClient.instance.fetchQueue(queueId), isA>()); + MQClient.instance.deleteQueue(queueId); + }); + + test( + "declareQueue should return name of queue even if it's already " + 'registered', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + + final queueId2 = MQClient.instance.declareQueue('test-queue'); + + expect(queueId, equals(queueId2)); + + MQClient.instance.deleteQueue(queueId); + }); + + test( + 'fetchQueue should throw QueueNotRegisteredException if the queue does ' + 'not exist.', () { + expect( + () => MQClient.instance.fetchQueue('test-queue'), + throwsA(isA()), + ); + }); + + test( + 'getLatestMessage should throw QueueNotRegisteredException if the ' + 'queue does not exist.', () { + expect( + () => MQClient.instance.getLatestMessage('test-queue'), + throwsA(isA()), + ); + }); + + test('deleteQueue should delete a queue', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + MQClient.instance.deleteQueue(queueId); + expect( + () => MQClient.instance.fetchQueue(queueId), + throwsA(isA()), + ); + }); + + test( + 'deleteQueue should throw QueueNotRegisteredException if the queue ' + 'does not exist.', () { + expect( + () => MQClient.instance.deleteQueue('test-queue'), + throwsA(isA()), + ); + }); + + test( + 'deleteQueue should throw QueueHasSubscribersException if there are ' + 'any consumers consuming that queue.', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queueStream = MQClient.instance.fetchQueue(queueId); + final sub = queueStream.listen((_) {}); + + expect( + () => MQClient.instance.deleteQueue(queueId), + throwsA(isA()), + ); + + sub.cancel(); + MQClient.instance.deleteQueue(queueId); + }); + }); + + group('Exchange Operations', () { + setUpAll(() => MQClient); + setUp(() { + MQClient.initialize(); + }); + test('declareExchange should declare a new exchange of the specified type', + () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + + MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding', + ); + + MQClient.instance.sendMessage( + message: Message(payload: 'test'), + exchangeName: exchangeName, + routingKey: 'test-binding', + ); + + expect( + MQClient.instance.getLatestMessage(queueId)?.payload, + equals('test'), + ); + + MQClient.instance.deleteExchange(exchangeName); + MQClient.instance.deleteQueue(queueId); + }); + + test( + 'sendMessage to unregister exchange should throw ' + 'ExchangeNotRegisteredException', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.sendMessage( + message: Message(payload: 'test'), + exchangeName: exchangeName, + routingKey: 'test-binding', + ), + throwsA(isA()), + ); + }); + + test( + 'declareExchange should throw InvalidExchangeTypeException if the ' + 'exchange type is invalid', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.base, + ), + throwsA(isA()), + ); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('deleteExchange should delete an exchange', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.bindQueue( + queueId: 'test-queue', + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + }); + + test('deleteExchange should do nothing if the exchange does not exist', () { + expect( + () => MQClient.instance.deleteExchange('nonexistent_exchange'), + returnsNormally, + ); + }); + + test('bindQueue should bind a queue to direct exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'key', + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('bindQueue should bind a queue to fanout exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.fanout, + ); + MQClient.instance.bindQueue(queueId: queueId, exchangeName: exchangeName); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'bindQueue should throw BindingKeyRequiredException if bindingKey is ' + 'not provided for DirectExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('unbindQueue should unbind a queue from an exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding-key', + ), + returnsNormally, + ); + MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding-key', + ); + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'unbindQueue should throw BindingKeyRequiredException if ' + 'bindingKey is not provided for DirectExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + test( + 'unbindQueue should not throw BindingKeyRequiredException if ' + 'bindingKey is not provided for FanoutExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.fanout, + ); + expect( + () => MQClient.instance + .unbindQueue(queueId: queueId, exchangeName: exchangeName), + returnsNormally, + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'unbindQueue should throw ExchangeNotRegisteredException ' + 'if exchange does not exist', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + }); + }); + + group('Close Operations.', () { + setUpAll(() => MQClient); + setUp(() { + MQClient.initialize(); + }); + test('close should close the MQClient', () { + MQClient.instance.declareQueue('test-queue'); + MQClient.instance.close(); + expect( + () => MQClient.instance, + throwsA(isA()), + ); + }); + }); +} diff --git a/sandbox/mqueue/test/producer/producer_test.dart b/sandbox/mqueue/test/producer/producer_test.dart new file mode 100644 index 0000000..1b3494c --- /dev/null +++ b/sandbox/mqueue/test/producer/producer_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'package:angel3_mq/mq.dart'; +import 'package:test/test.dart'; + +class MyMessageProducer with ProducerMixin { + // Custom implementation of the message producer. +} + +void main() { + group('Producer', () { + final producer = MyMessageProducer(); + + setUpAll(() { + MQClient.initialize(); + + MQClient.instance.declareQueue('test-queue'); + }); + + test( + 'sendMessage should send a message to an exchange and call the ' + 'callback', () { + final message = Message( + payload: 'Test Message', + timestamp: '2023-09-07T12:00:002', + ); + var callbackCalled = false; + producer + ..setPushCallback((message) { + callbackCalled = true; + }) + ..sendMessage( + payload: 'Test Message', + exchangeName: '', + routingKey: 'test-queue', + timestamp: '2023-09-07T12:00:002', + ); + + expect( + MQClient.instance.getLatestMessage('test-queue')?.headers, + equals(message.headers), + ); + expect( + MQClient.instance.getLatestMessage('test-queue')?.payload, + equals(message.payload), + ); + expect( + MQClient.instance.getLatestMessage('test-queue')?.timestamp, + equals(message.timestamp), + ); + expect(callbackCalled, isTrue); + }); + + test( + 'sendRPCMessage should send an RPC message to an exchange and call the ' + 'callback', () async { + var callbackCalled = false; + + producer.setPushCallback((message) { + callbackCalled = true; + }); + + final sub = MQClient.instance.fetchQueue('test-queue').listen((message) { + if (message.headers['type'] == 'RPC') { + (message.headers['completer'] as Completer).complete('Response'); + return; + } + }); + + final res = await producer.sendRPCMessage( + processId: 'foo', + args: {'key': 'value'}, + exchangeName: '', + routingKey: 'test-queue', + ); + + expect(callbackCalled, isTrue); + + expect(res, equals('Response')); + + await sub.cancel(); + }); + + test('sendRPCMessage with non-null mapper', () async { + var callbackCalled = false; + producer.setPushCallback((message) { + callbackCalled = true; + }); + + final sub = + MQClient.instance.fetchQueue('test-queue').listen((message) async { + if (message.headers['type'] == 'RPC') { + (message.headers['completer'] as Completer).complete('Response'); + return; + } + }); + + final response = await producer.sendRPCMessage( + processId: 'foo', + args: {'key': 'value'}, + exchangeName: '', + routingKey: 'test-queue', + mapper: (data) => '$data-new', + ); + + expect(callbackCalled, isTrue); + expect(response, equals('Response-new')); + + await sub.cancel(); + }); + }); +} diff --git a/sandbox/mqueue/test/queue/queue_test.dart b/sandbox/mqueue/test/queue/queue_test.dart new file mode 100644 index 0000000..0b80082 --- /dev/null +++ b/sandbox/mqueue/test/queue/queue_test.dart @@ -0,0 +1,98 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + group('Queue', () { + test('Creating a Queue', () { + // Arrange + const queueId = 'my_queue_id'; + + // Act + final myQueue = Queue(queueId); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, isNull); + }); + + test('Get dataStream from Queue', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + + // Act + final dataStream = myQueue.dataStream; + + // Assert + expect(dataStream, isNotNull); + }); + + test('Enqueue and Check Has Listeners', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + // Act + myQueue.enqueue(message); + final hasListeners = myQueue.hasListeners(); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, equals(message)); + expect(hasListeners, isFalse); // No listeners by default + }); + + test('Queue equality', () { + // Arrange + final queue1 = Queue('queue_id_1'); + final queue2 = Queue('queue_id_2'); + final queue3 = Queue('queue_id_1'); // Same ID as queue1 + + // Act & Assert + expect(queue1, equals(queue3)); // Should be equal based on ID + expect( + queue1, + isNot(equals(queue2)), + ); // Should not be equal due to different IDs + }); + + test('Queue hashCode', () { + // Arrange + final queue1 = Queue('queue_id_1'); + final queue2 = Queue('queue_id_2'); + final queue3 = Queue('queue_id_1'); // Same ID as queue1 + + // Act & Assert + expect(queue1.hashCode, equals(queue3.hashCode)); + expect(queue1.hashCode, isNot(equals(queue2.hashCode))); + }); + + test('Queue dispose', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + // Act + myQueue + ..enqueue(message) + ..dispose(); + final hasListeners = myQueue.hasListeners(); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, equals(message)); + expect(hasListeners, isFalse); + }); + }); +} diff --git a/sandbox/reactivex/.gitignore b/sandbox/reactivex/.gitignore new file mode 100644 index 0000000..454fea2 --- /dev/null +++ b/sandbox/reactivex/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ +coverage/ \ No newline at end of file diff --git a/sandbox/reactivex/CHANGELOG.md b/sandbox/reactivex/CHANGELOG.md new file mode 100644 index 0000000..9c6e7a0 --- /dev/null +++ b/sandbox/reactivex/CHANGELOG.md @@ -0,0 +1,775 @@ +# Changelog + +## [0.28.0] (2024-06-14) + +### New + +* ValueStream: + * Add `lastEventOrNull` getter to `ValueStream`, + which returns the last emitted event (either data/value or error event), or `null`. + * Add `isLastEventValue`, `isLastEventError` and `errorAndStackTraceOrNull` + extension getters to `ValueStream`, to check the kind of the last emitted event is data/value or error. + * Update documentation. + +* ReplayStream: + * Add `errorAndStackTraces` to `ReplayStream`, which returns a list of emitted `ErrorAndStackTrace`s. + +* Rename `Notification` and `Kind` to better reflect their purpose, + and to avoid confusion with [Flutter's Notification class](https://api.flutter.dev/flutter/widgets/Notification-class.html). + * Rename `Notification` to `StreamNotification` + * `Notification.onData` to `StreamNotification.data`. + * `Notification.onDone` to `StreamNotification.done`. + * `Notification.onError` to `StreamNotification.error`. + * Rename `Kind` to `NotificationKind` + * `Kind.onData` to `NotificationKind.data`. + * `Kind.onError` to `NotificationKind.error`. + * `Kind.onDone` to `NotificationKind.done`. + * Introduce `DataNotification`, `ErrorNotification` and `DoneNotification` as the subclasses of `StreamNotification`. + * Convert `isOnData`, `isOnError`, `isOnDone`, `requireData` to extension getters on `StreamNotification`, + they are now named `isData`, `isError`, `isDone` and `requireDataValue`. + * Add extensions on `StreamNotification`: `dataValueOrNull`, `requireErrorAndStackTrace`, `errorAndStackTraceOrNull` getters and `when` method. + +### Changed + +* Accept Dart SDK versions above 3.0. +* `switchMap`: when cancelling the previous inner subscription, + `switchMap` will pause the outer subscription and and wait for the inner subscription to be completely canceled. + It will then resume the outer subscription, and listen to the next inner Stream. + Any errors from canceling the previous inner subscription will now be forwarded to the resulting Stream. + +* **Breaking**: Rename `ForkJoinStream.combine2`..`combine9` to `ForkJoinStream.join2`..`join9`. +* **Breaking**: `Rx.using`/`UsingStream` + * Convert all _required positional_ parameters to _required named_ parameters. + * The `disposer` is now called after the future returned from `StreamSubscription.cancel` completes. + +### Documentation + +* Update and fix documentation. +* Fix README example (thanks to [@wurikiji](https://github.com/wurikiji)). +* Update Flutter example (thanks to [@hoangchungk53qx1](https://github.com/hoangchungk53qx1)). +* Replace deprecated "dart pub run" with "dart run" (thanks to [@tatsuyafujisaki](https://github.com/tatsuyafujisaki)). + +## [0.28.0-dev.2] (2024-03-30) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### Changed + +* **Breaking**: Rename `ForkJoinStream.combine2`..`combine9` to `ForkJoinStream.join2`..`join9`. +* **Breaking**: `Rx.using`/`UsingStream` + * Convert all _required positional_ parameters to _required named_ parameters. + * The `disposer` is now called after the future returned from `StreamSubscription.cancel` completes. + +## [0.28.0-dev.1] (2024-01-27) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### Changed + +* `switchMap`: when cancelling the previous inner subscription, + `switchMap` will pause the outer subscription and and wait for the inner subscription to be completely canceled. + It will then resume the outer subscription, and listen to the next inner Stream. + Any errors from canceling the previous inner subscription will now be forwarded to the resulting Stream. + +### Documentation + +* Replace deprecated "dart pub run" with "dart run" (thanks to [@tatsuyafujisaki](https://github.com/tatsuyafujisaki)). + +## [0.28.0-dev.0] (2023-07-26) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### New + +* ValueStream: + * Add `lastEventOrNull` getter to `ValueStream`, + which returns the last emitted event (either data/value or error event), or `null`. + * Add `isLastEventValue`, `isLastEventError` and `errorAndStackTraceOrNull` + extension getters to `ValueStream`, to check the kind of the last emitted event is data/value or error. + * Update documentation. + +* ReplayStream: + * Add `errorAndStackTraces` to `ReplayStream`, which returns a list of emitted `ErrorAndStackTrace`s. + +* Rename `Notification` and `Kind` to better reflect their purpose, + and to avoid confusion with [Flutter's Notification class](https://api.flutter.dev/flutter/widgets/Notification-class.html). + * Rename `Notification` to `StreamNotification` + * `Notification.onData` to `StreamNotification.data`. + * `Notification.onDone` to `StreamNotification.done`. + * `Notification.onError` to `StreamNotification.error`. + * Rename `Kind` to `NotificationKind` + * `Kind.onData` to `NotificationKind.data`. + * `Kind.onError` to `NotificationKind.error`. + * `Kind.onDone` to `NotificationKind.done`. + * Introduce `DataNotification`, `ErrorNotification` and `DoneNotification` as the subclasses of `StreamNotification`. + * Convert `isOnData`, `isOnError`, `isOnDone`, `requireData` to extension getters on `StreamNotification`, + they are now named `isData`, `isError`, `isDone` and `requireDataValue`. + * Add extensions on `StreamNotification`: `dataValueOrNull`, `requireErrorAndStackTrace`, `errorAndStackTraceOrNull` getters and `when` method. + +### Changed + +* Accept Dart SDK versions above 3.0. + +### Documentation + +* Update and fix documentation. +* Fix README example (thanks to [@wurikiji](https://github.com/wurikiji)). +* Update Flutter example (thanks to [@hoangchungk53qx1](https://github.com/hoangchungk53qx1)). + +## 0.27.7 (2022-11-16) + +### Fixed + +* `Subject` + * Only call `onAdd` and `onError` if the subject is not closed. + This ensures `BehaviorSubject` and `ReplaySubject` do not update their values after they have been closed. + + * `Subject.stream` now returns a **read-only** `Stream`. + Previously, `Subject.stream` was identical to the `Subject`, so we could add events to it, for example: `(subject.stream as Sink).add(event)`. + This behavior is now disallowed, and will throw a `TypeError` if attempted. Use `Subject.sink`/`Subject` itself for adding events. + + * Change return type of `ReplaySubject.stream` to `ReplayStream`. + * Internal refactoring of `Subject.addStream`. + +## 0.27.6 (2022-11-11) + +* `Rx.using`/`UsingStream`: `resourceFactory` can now return a `Future`. + This allows for asynchronous resource creation. + +* `Rx.range`/`RangeStream`: ensure `RangeStream` is only listened to once. + +## 0.27.5 (2022-07-16) + +### Bug fixes + +* Fix issue [#683](https://github.com/ReactiveX/rxdart/issues/683): Throws runtime type error when using extension + methods on a `Stream` but its type annotation is `Stream`, `R` is a subtype of `T` + (covariance issue with `StreamTransformer`). + ```Dart + Stream s1 = Stream.fromIterable([1, 2, 3]); + // throws "type 'SwitchMapStreamTransformer' is not a subtype of type 'StreamTransformer' of 'streamTransformer'" + s1.switchMap((v) => Stream.value(v)); + + Stream s2 = Stream.fromIterable([1, 2, 3]); + // throws "type 'SwitchMapStreamTransformer' is not a subtype of type 'StreamTransformer' of 'streamTransformer'" + s2.switchMap((v) => Stream.value(v)); + ``` + Extension methods were previously implemented via `stream.transform(streamTransformer)`, now + via `streamTransformer.bind(stream)` to avoid this issue. + +* Fix `concatEager`: `activeSubscription` should be changed to next subscription. + +### Code refactoring + +* Change return type of `pairwise` to `Stream>`. + +## 0.27.4 (2022-05-29) + +### Bug fixes + +* `withLatestFrom` should iterate over `Iterable` only once when the stream is listened to. +* Fix analyzer warnings when using `Dart 2.16.0`. + +### Features + +* Add `mapNotNull`/`MapNotNullStreamTransformer`. +* Add `whereNotNull`/`WhereNotNullStreamTransformer`. + +### Documentation + +* Fix grammar errors in code examples (thanks to [@fzyzcjy](https://github.com/fzyzcjy)). +* Update RxMarbles URL for `RaceStream` (thanks to [@Péter Ferenc Gyarmati](https://github.com/peter-gy)). + +## 0.27.3 (2021-11-21) + +### Bug fixes + +* `flatMap` now creates inner `Stream`s lazily. +* `combineLatest`, `concat`, `concatEager`, `forkJoin`, `merge`, `race`, `zip` iterate over `Iterable`s only once + when the stream is listened to. +* Disallow mixing `autoConnect`, `connect` and `refCount` together, only one of them should be used. + +### Features + +* Introduce `AbstractConnectableStream`, base class for the `ConnectableStream` implementations. +* Improve `CompositeSubscription` (thanks to [@BreX900](https://github.com/BreX900)) + * CompositeSubscription's `dispose`, `clear`, and `remove` methods now return a completion future. + * Fixed an issue where a stream not present in CompositeSubscription was canceled. + * Added the ability not to cancel the stream when it is removed from CompositeSubscription. + * CompositeSubscription implements `StreamSubscription`. + * `CompositeSubscription.add` will throw a `StateError` instead of a `String` if this composite was disposed. + +### Documentation + +* Fix `Connectable` examples. +* Update Web example to null safety. +* Fix `Flutter` example: `SearchResultItem.fromJson` type error (thanks to [@WenYeh](https://github.com/wayne900204)) + +### Code refactoring + +* Simplify `takeLast` implementation. +* Migrate from `pedantic` to `lints` and `flutter_lints`. +* Refactor `BehaviorSubject`, `ReplaySubject` implementations by using "`Sentinel object`"s instead of `ValueWrapper`s. + +## 0.27.2 (2021-09-03) + +### Bug fixes + +* `onErrorReturnWith` now does not drop the remaining data events after the first error. +* Disallow changing handlers of `ConnectableStreamSubscription`. + +### Features + +* Add `delayWhen` operator. +* Add optional parameter `maxConcurrent` to `flatMap`. +* `groupBy` + * Rename `GroupByStream` to `GroupedStream`. + * Add optional parameter `durationSelector`, which used to determine how long each group should exist. +* `ignoreElements` + * Remove `@deprecated` annotation (`ignoreElements` should not be marked as deprecated). + * Change return type to `Stream`. + +### Documentation + +* Update to `PublishSubject`'s docs (thanks to [@AlexanderJohr](https://github.com/AlexanderJohr)). + +### Code refactoring + +* Refactoring Stream Transformers, using `Stream.multi` internally. + +## 0.27.1 + +* Bugfix: `ForkJoinStream` throws `Null check operator used on a null value` when using nullable-type. +* Bugfix: `delay` operator + * Pause and resume properly. + * Cancel all timers after it has been cancelled. + +## 0.27.0 + * **BREAKING: ValueStream** + * Remove `ValueStreamExtensions`. + * `ValueStream.valueWrapper` becomes + - `value`. + - `valueOrNull`. + - `hasValue`. + * `ValueStream.errorAndStackTrace` becomes + - `error`. + - `errorOrNull`. + - `hasError`. + - `stackTrace`. + * Add `skipLast`/`SkipLastStreamTransformer` (thanks [@HannibalKcc](https://github.com/HannibalKcc)). + * Update `scan`: change `seed` to required param. + * Add `StackTrace` param to `recoveryFn` when using `OnErrorResumeStreamTransformer`/`onErrorResume`/`onErrorReturnWith`. + * Internal refactoring `ConnectableStream`. + +## 0.26.0 + * Stable, null-safe release. + * Add `takeLast` (thanks [@ThomasKliszowski](https://github.com/ThomasKliszowski)). + * Rework for `retry`/`retryWhen`: + * Removed `RetryError`. + * `retry`: emits all errors if retry fails. + * `retryWhen`: emits original error, and error from factory if they are not identical. + * `streamFactory` now accepts non-nullable `StackTrace` argument. + * Update `ValueStream.requireValue` and `ValueStream.requireError`: throws actual error or a `StateError`, + instead of throwing `"Null check operator used on a null value"` error. + +## 0.26.0-nullsafety.1 + * Breaking change: `ValueStream` + - Add `valueWrapper` to `ValueStream`. + - Change `value`, `hasValue`, `error` and `hasError` to extension getters. + * Fixed some API example documentation (thanks [@HannibalKcc](https://github.com/HannibalKcc)). + * `throttle`/`throttleTime` have been optimised for performance. + * Updated Flutter example to work with the latest Flutter stable. + +## 0.26.0-nullsafety.0 + * Migrate this package to null safety. + * Sdk constraints: `>=2.12.0-0 <3.0.0` based on beta release guidelines. + +## 0.25.0 + * Sync behavior when using `publishValueSeeded`. + * `ValueStream`, `ReplayStream`: exposes `stackTrace` along with the `error`: + * Change `ValueStream.error` to `ValueStream.errorAndStackTrace`. + * Change `ReplayStream.errors` to `ReplayStream.errorAndStackTraces`. + * Merge `Notification.error` and `Notification.stackTrace` into `Notification.errorAndStackTrace`. + * Bugfix: `debounce`/`debounceTime` unnecessarily kept too many elements in queue. + +## 0.25.0-beta3 + * Bugfix: `switchMap` doesn't close after the last inner Stream closes. + * Docs: updated URL for "Single-Subscription vs. Broadcast Streams" doc (thanks [Aman Gupta](https://github.com/Aman9026)). + * Add `FromCallableStream`/`Rx.fromCallable`: allows you to create a `Stream` from a callable function. + * Override `BehaviorSubject`'s built-in operators to correct replaying the latest value of `BehaviorSubject`. + * Bugfix: Source `StreamSubscription` doesn't cancel when cancelling `refCount`, `zip`, `merge`, `concat` StreamSubscription. + * Forward done event of upstream to `ConnectableStream`. + +## 0.25.0-beta2 + * Internal refactoring Stream Transformers. + * Fixed `RetryStream` example documentation. + * Error thrown from `DeferStream` factory will now be caught and converted to `Stream.error`. + * `doOnError` now have strong type signature: `Stream doOnError(void Function(Object, StackTrace) onError)`. + * Updated `ForkJoinStream`: + * When any Stream emits an error, listening still continues unless `cancelOnError: true` on the downstream. + * Pause and resume Streams properly. + * Added `UsingStream`. + * Updated `TimerStream`: Pause and resume Timer when pausing and resuming StreamSubscription. + +## 0.25.0-beta + * stream transformations on a ValueStream will also return a ValueStream, instead of + a standard broadcast Stream + * throttle can now be both leading and trailing + * better handling of empty Lists when using operators that accept a List as input + * error & hasError added to BehaviorSubject + * various docs updates + * note that this is a beta release, mainly because the behavior of transform has been adjusted (see first bullet) + if all goes well, we'll release a proper 0.25.0 release soon + +## 0.24.1 + * Fix for BehaviorSubject, no longer emits null when using addStream and expecting an Error as first event (thanks [yuvalr1](https://github.com/yuvalr1)) + * min/max have been optimised for performance + * Further refactors on our Transformers + +## 0.24.0 + * Fix throttle no longer outputting the current buffer onDone + * Adds endWith and endWithMany + * Fix when using pipe and an Error, Subjects would throw an Exception that couldn't be caught using onError + * Updates links for docs (thanks [@renefloor](https://github.com/renefloor)) + * Fix links to correct marbles diagram for debounceTime (thanks [@wheater](https://github.com/Wheater)) + * Fix flakiness of withLatestFrom test Streams + * Update to docs ([@wheater](https://github.com/Wheater)) + * Fix withLatestFrom not pause/resume/cancelling underlying Streams + * Support sync behavior for Subjects + * Add addTo extension for StreamSubscription, use it to easily add a subscription to a CompositeSubscription + * Fix mergeWith and zipWith will return a broadcast Stream, if the source Stream is also broadcast + * Fix concatWith will return a broadcast Stream, if the source Stream is also broadcast (thanks [@jarekb123](https://github.com/jarekb123)) + * Adds pauseAll, resumeAll, ... to CompositeSubscription + * Additionally, fixes some issues introduced with 0.24.0-dev.1 + +## 0.24.0-dev.1 + * Breaking: as of this release, we've refactored the way Stream transformers are set up. + Previous releases had some incorrect behavior when using certain operators, for example: + - startWith (startWithMany, startWithError) + would incorrectly replay the starting event(s) when using a + broadcast Stream at subscription time. + - doOnX was not always producing the expected results: + * doOnData did not output correct sequences on streams that were transformed + multiple times in sequence. + * doOnCancel now acts in the same manner onCancel works on + regular subscriptions, i.e. it will now be called when all + active subscriptions on a Stream are cancelled. + * doOnListen will now call the first time the Stream is + subscribed to, and will only call again after all subscribers + have cancelled, before a new subscription starts. + + To properly fix this up, a new way of transforming Streams was introduced. + Operators as of now use Stream.eventTransformed and we've refactored all + operators to implement Sink instead. + * Adds takeWileInclusive operator (thanks to [@hoc081098](https://github.com/hoc081098)) + + We encourage everyone to give the dev release(s) a spin and report back if + anything breaks. If needed, a guide will be written to help migrate from + the old behavior to the new behavior in certain common use cases. + + Keep in mind that we tend to stick as close as we can to how normal + Dart Streams work! + +## 0.23.1 + + * Fix API doc links in README + +## 0.23.0 + + * Extension Methods replace `Observable` class! + * Please upgrade existing code by using the rxdart_codemod package + * Remove the Observable class. With extensions, you no longer need to wrap Streams in a [Stream]! + * Convert all factories to static constructors to aid in discoverability of Stream classes + * Move all factories to an `Rx` class. + * Remove `Observable.just`, use `Stream.value` + * Remove `Observable.error`, use `Stream.error` + * Remove all tests that check base Stream methods + * Subjects and *Observable classes extend Stream instead of base Observable + * Rename *Observable to *Stream to reflect the fact they're just Streams. + * `ValueObservable` -> `ValueStream` + * `ReplayObservable` -> `ReplayStream` + * `ConnectableObservable` -> `ConnectableStream` + * `ValueConnectableObservable` -> `ValueConnectableStream` + * `ReplayConnectableObservable` -> `ReplayConnectableStream` + * All transformation methods removed from Observable class + * Transformation methods are now Extensions of the Stream class + * Any Stream can make use of the transformation methods provided by RxDart + * Observable class remains in place with factory methods to create different types of Streams + * Removed deprecated `ofType` method, use `whereType` instead + * Deprecated `concatMap`, use standard Stream `asyncExpand`. + * Removed `AsObservableFuture`, `MinFuture`, `MaxFuture`, and `WrappedFuture` + * This removes `asObservable` method in chains + * Use default `asStream` method from the base `Future` class instead. + * `min` and `max` now implemented directly on the Stream class + +## 0.23.0-dev.3 + + * Fix missing exports: + - `ValueStream` + - `ReplayStream` + - `ConnectableStream` + - `ValueConnectableStream` + - `ReplayConnectableStream` + +## 0.23.0-dev.2 + * Remove the Observable class. With extensions, you no longer need to wrap Streams in a [Stream]! + * Convert all factories to static constructors to aid in discoverability of Stream classes + * Move all factories to an `Rx` class. + * Remove `Observable.just`, use `Stream.value` + * Remove `Observable.error`, use `Stream.error` + * Remove all tests that check base Stream methods + * Subjects and *Observable classes extend Stream instead of base Observable + * Rename *Observable to *Stream to reflect the fact they're just Streams. + * `ValueObservable` -> `ValueStream` + * `ReplayObservable` -> `ReplayStream` + * `ConnectableObservable` -> `ConnectableStream` + * `ValueConnectableObservable` -> `ValueConnectableStream` + * `ReplayConnectableObservable` -> `ReplayConnectableStream` + +## 0.23.0-dev.1 + * Feedback on this change appreciated as this is a dev release before 0.23.0 stable! + * All transformation methods removed from Observable class + * Transformation methods are now Extensions of the Stream class + * Any Stream can make use of the transformation methods provided by RxDart + * Observable class remains in place with factory methods to create different types of Streams + * Removed deprecated `ofType` method, use `whereType` instead + * Deprecated `concatMap`, use standard Stream `asyncExpand`. + * Removed `AsObservableFuture`, `MinFuture`, `MaxFuture`, and `WrappedFuture` + * This removes `asObservable` method in chains + * Use default `asStream` method from the base `Future` class instead. + * `min` and `max` now implemented directly on the Stream class + +## 0.22.6 + * Bugfix: When listening multiple times to a`BehaviorSubject` that starts with an Error, + it emits duplicate events. + * Linter: public_member_api_docs is now used, we have added extra documentation + where required. + +## 0.22.5 + * Bugfix: DeferStream created Stream too early + * Bugfix: TimerStream created Timer too early + +## 0.22.4 + * Bugfix: switchMap controller no longer closes prematurely + +## 0.22.3 + * Bugfix: whereType failing in Flutter production builds only + +## 0.22.2 + * Bugfix: When using a seeded `BehaviorSubject` and adding an `Error`, + upon listening, the `BehaviorSubject` emits `null` instead of the last `Error`. + * Bugfix: calling cancel after a `switchMap` can cause a `NoSuchMethodError`. + * Updated Flutter example to match the latest Flutter release + * `Observable.withLatestFrom` is now expanded to accept 2 or more `Stream`s + thanks to Petrus Nguyễn Thái Học (@hoc081098)! + * Deprecates `ofType` in favor of `whereType`, drop `TypeToken`. + +## 0.22.1 + Fixes following issues: + * Erroneous behavior with scan and `BehaviorSubject`. + * Bug where `flatMap` would cancel inner subscriptions in `pause`/`resume`. + * Updates to make the current "pedantic" analyzer happy. + +## 0.22.0 + This version includes refactoring for the backpressure operators: + * Breaking Change: `debounce` is now split into `debounce` and `debounceTime`. + * Breaking Change: `sample` is now split into `sample` and `sampleTime`. + * Breaking Change: `throttle` is now split into `throttle` and `throttleTime`. + +## 0.21.0 + * Breaking Change: `BehaviorSubject` now has a separate factory constructor `seeded()` + This allows you to seed this Subject with a `null` value. + * Breaking Change: `BehaviorSubject` will now emit an `Error`, if the last event was also an `Error`. + Before, when an `Error` occurred before a `listen`, the subscriber would not be notified of that `Error`. + To refactor, simply change all occurences of `BehaviorSubject(seedValue: value)` to `BehaviorSubject.seeded(value)` + * Added the `groupBy` operator + * Bugix: `doOnCancel`: will now await the cancel result, if it is a `Future`. + * Removed: `bufferWithCount`, `windowWithCount`, `tween` + Please use `bufferCount` and `windowCount`, `tween` is removed, because it never was an official Rx spec. + * Updated Flutter example to work with the latest Flutter stable. + +## 0.20.0 + * Breaking Change: bufferCount had buggy behavior when using `startBufferEvery` (was `skip` previously) + If you were relying on bufferCount with `skip` greater than 1 before, then you may have noticed + erroneous behavior. + * Breaking Change: `repeat` is no longer an operator which simply repeats the last emitted event n-times, + instead this is now an Observable factory method which takes a StreamFactory and a count parameter. + This will cause each repeat cycle to create a fresh Observable sequence. + * `mapTo` is a new operator, which works just like `map`, but instead of taking a mapper Function, it takes + a single value where each event is mapped to. + * Bugfix: switchIfEmpty now correctly calls onDone + * combineLatest and zip can now take any amount of Streams: + * combineLatest2-9 & zip2-9 functionality unchanged, but now use a new path for construction. + * adds combineLatest and zipLatest which allows you to pass through an Iterable> and a combiner that takes a List when any source emits a change. + * adds combineLatestList / zipList which allows you to take in an Iterable> and emit a Observable> with the values. Just a convenience factory if all you want is the list! + * Constructors are provided by the Stream implementation directly + * Bugfix: Subjects that are transformed will now correctly return a new Observable where isBroadcast is true (was false before) + * Remove deprecated operators which were replaced long ago: `bufferWithCount`, `windowWithCount`, `amb`, `flatMapLatest` + +## 0.19.0 + + * Breaking Change: Subjects `onCancel` function now returns `void` instead of `Future` to properly comply with the `StreamController` signature. + * Bugfix: FlatMap operator properly calls onDone for all cases + * Connectable Observable: An observable that can be listened to multiple times, and does not begin emitting values until the `connect` method is called + * ValueObservable: A new interface that allows you to get the latest value emitted by an Observable. + * Implemented by BehaviorSubject + * Convert normal observables into ValueObservables via `publishValue` or `shareValue` + * ReplayObservable: A new interface that allows you to get the values emitted by an Observable. + * Implemented by ReplaySubject + * Convert normal observables into ReplayObservables via `publishReplay` or `shareReplay` + +## 0.18.1 + +* Add `retryWhen` operator. Thanks to Razvan Lung (@long1eu)! This can be used for custom retry logic. + +## 0.18.0 + +* Breaking Change: remove `retype` method, deprecated as part of Dart 2. +* Add `flatMapIterable` + +## 0.17.0 + +* Breaking Change: `stream` property on Observable is now private. + * Avoids API confusion + * Simplifies Subject implementation + * Require folks who are overriding the `stream` property to use a `super` constructor instead +* Adds proper onPause and onResume handling for `amb`/`race`, `combineLatest`, `concat`, `concat_eager`, `merge` and `zip` +* Add `switchLatest` operator +* Add errors and stacktraces to RetryError class +* Add `onErrorResume` and `onErrorRetryWith` operators. These allow folks to return a specific stream or value depending on the error that occurred. + +## 0.16.7 + +* Fix new buffer and window implementation for Flutter + Dart 2 +* Subject now implements the Observable interface + +## 0.16.6 + +* Rework for `buffer` and `window`, allow to schedule using a sampler +* added `buffer` +* added `bufferFuture` +* added `bufferTest` +* added `bufferTime` +* added `bufferWhen` +* added `window` +* added `windowFuture` +* added `windowTest` +* added `windowTime` +* added `windowWhen` +* added `onCount` sampler for `buffer` and `window` +* added `onFuture` sampler for `buffer` and `window` +* added `onTest` sampler for `buffer` and `window` +* added `onTime` sampler for `buffer` and `window` +* added `onStream` sampler for `buffer` and `window` + +## 0.16.5 + +* Renames `amb` to `race` +* Renames `flatMapLatest` to `switchMap` +* Renames `bufferWithCount` to `bufferCount` +* Renames `windowWithCount` to `windowCount` + +## 0.16.4 + +* Adds `bufferTime` transformer. +* Adds `windowTime` transformer. + +## 0.16.3 + +* Adds `delay` transformer. + +## 0.16.2 + +* Fix added events to `sink` are not processed correctly by `Subjects`. + +## 0.16.1 + +* Fix `dematerialize` method for Dart 2. + +## 0.16.0+2 + +* Add `value` to `BehaviorSubject`. Allows you to get the latest value emitted by the subject if it exists. +* Add `values` to `ReplayrSubject`. Allows you to get the values stored by the subject if any exists. + +## 0.16.0+1 + +* Update Changelog + +## 0.16.0 + +* **breaks backwards compatibility**, this release only works with Dart SDK >=2.0.0. +* Removed old `cast` in favour of the now native Stream cast method. +* Override `retype` to return an `Observable`. + +## 0.15.1 + +* Add `exhaustMap` map to inner observable, ignore other values until that observable completes. +* Improved code to be dartdevc compatible. +* Add upper SDK version limit in pubspec + +## 0.15.0 + +* Change `debounce` to emit the last item of the source stream as soon as the source stream completes. +* Ensure `debounce` does not keep open any addition async timers after it has been cancelled. + +## 0.14.0+1 + +* Change `DoStreamTransformer` to return a `Future` on cancel for api compatibility. + +## 0.14.0 + +* Add `PublishSubject` (thanks to @pauldemarco) +* Fix bug with `doOnX` operators where callbacks were fired too often + +## 0.13.1 + +* Fix error with FlatMapLatest where it was not properly cancelled in some scenarios +* Remove additional async methods on Stream handlers unless they're shown to solve a problem + +## 0.13.0 + +* Remove `call` operator / `StreamTransformer` entirely +* Important bug fix: Errors thrown within any Stream or Operator will now be properly sent to the `StreamSubscription`. +* Improve overall handling of errors throughout the library to ensure they're handled correctly + +## 0.12.0 + +* Added doOn* operators in place of `call`. +* Added `DoStreamTransformer` as a replacement for `CallStreamTransformer` +* Deprecated `call` and `CallStreamTransformer`. Please use the appropriate `doOnX` operator / transformer. +* Added `distinctUnique`. Emits items if they've never been emitted before. Same as to Rx#distinct. + +## 0.11.0 + +* !!!Breaking Api Change!!! + * Observable.groupBy has been removed in order to be compatible with the next version of the `Stream` class in Dart 1.24.0, which includes this method + +## 0.10.2 + +* BugFix: The new Subject implementation no longer causes infinite loops when used with ng2 async pipes. + +## 0.10.1 + +* Documentation fixes + +## 0.10.0 + +* Api Changes + * Observable + * Remove all deprecated methods, including: + * `observable` factory -- replaced by the constructor `new Observable()` + * `combineLatest` -- replaced by Strong-Mode versions `combineLatest2` - `combineLatest9` + * `zip` -- replaced by Strong-Mode versions `zip2` - `zip9` + * Support `asObservable` conversion from Future-returning methods. e.g. `new Observable.fromIterable([1, 2]).first.asObservable()` + * Max and Min now return a Future of the Max or Min value, rather than a stream of increasing or decreasing values. + * Add `cast` operator + * Remove `ConcatMapStreamTransformer` -- functionality is already supported by `asyncExpand`. Keep the `concatMap` method as an alias. + * Subjects + * BehaviourSubject has been renamed to BehaviorSubject + * The subjects have been rewritten and include far more testing + * In keeping with the Rx idea of Subjects, they are broadcast-only +* Documentation -- extensive documentation has been added to the library with explanations and examples for each Future, Stream & Transformer. + * Docs detailing the differences between RxDart and raw Observables. + +## 0.9.0 + +* Api Changes: + * Convert all StreamTransformer factories to proper classes + * Ensure these classes can be re-used multiple times + * Retry has moved from an operator to a constructor. This is to ensure the stream can be properly re-constructed every time in the correct way. + * Streams now properly enforce the single-subscription contract +* Include example Flutter app. To run it, please follow the instructions in the README. + +## 0.8.3+1 +* rename examples map to example + +## 0.8.3 +* added concatWith, zipWith, mergeWith, skipUntil +* cleanup of the examples folder +* cleanup of examples code +* added fibonacci example +* added search GitHub example + +## 0.8.2+1 +* moved repo into ReactiveX +* update readme badges accordingly + +## 0.8.2 +* added materialize/dematerialize +* added range (factory) +* added timer (factory) +* added timestamp +* added concatMap + +## 0.8.1 +* added never constructor +* added error constructor +* moved code coverage to [codecov.io](https://codecov.io/gh/frankpepermans/rxdart) + +## 0.8.0 +* BREAKING: tap is replaced by call(onData) +* added call, which can take any combination of the following event methods: +onCancel, onData, onDone, onError, onListen, onPause, onResume + +## 0.7.1+1 +* improved the README file + +## 0.7.1 +* added ignoreElements +* added onErrorResumeNext +* added onErrorReturn +* added switchIfEmpty +* added empty factory constructor + +## 0.7.0 +* BREAKING: rename combineXXXLatest and zipXXX to a numbered equivalent, +for example: combineThreeLatest becomes combineLatest3 +* internal refactoring, expose streams/stream transformers as a separate library + +## 0.6.3+4 +* changed ofType to use TypeToken + +## 0.6.3+3 +* added ofType + +## 0.6.3+2 +* added defaultIfEmpty + +## 0.6.3+1 +* changed concat, old concat is now concatEager, new concat behaves as expected + +## 0.6.3 +* Added withLatestFrom +* Added defer ctr +(both thanks to [brianegan](https://github.com/brianegan "GitHub link")) + +## 0.6.2 +* Added just (thanks to [brianegan](https://github.com/brianegan "GitHub link")) +* Added groupBy +* Added amb + +## 0.6.1 +* Added concat + +## 0.6.0 +* BREAKING: startWith now takes just one parameter instead of an Iterable. To add multiple starting events, please use startWithMany. +* Added BehaviourSubject and ReplaySubject. These implement StreamController. +* BehaviourSubject will notify the last added event upon listening. +* ReplaySubject will notify all past events upon listening. +* DEPRECATED: zip and combineLatest, use their strong-type-friendly alternatives instead (available as static methods on the Observable class, i.e. Observable.combineThreeLatest, Observable.zipFour, ...) + +## 0.5.1 + +* Added documentation (thanks to [dustinlessard-wf](https://github.com/dustinlessard-wf "GitHub link")) +* Fix tests breaking due to deprecation of expectAsync +* Fix tests to satisfy strong mode requirements + +## 0.5.0 + +* As of this version, rxdart depends on SDK v1.21.0, to support the newly added generic method type syntax + +[Unreleased]: https://github.com/ReactiveX/rxdart/compare/0.28.0...HEAD +[0.28.0]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0 +[0.28.0-dev.2]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.2 +[0.28.0-dev.1]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.1 +[0.28.0-dev.0]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.0 \ No newline at end of file diff --git a/sandbox/reactivex/LICENSE b/sandbox/reactivex/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/sandbox/reactivex/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sandbox/reactivex/README.md b/sandbox/reactivex/README.md new file mode 100644 index 0000000..4f6f0da --- /dev/null +++ b/sandbox/reactivex/README.md @@ -0,0 +1,277 @@ +# RxDart + +

+build +

+ +

+RxDart +

+ +[![Build Status](https://github.com/ReactiveX/rxdart/workflows/Dart%20CI/badge.svg)](https://github.com/ReactiveX/rxdart/actions) +[![codecov](https://codecov.io/gh/ReactiveX/rxdart/branch/master/graph/badge.svg)](https://codecov.io/gh/ReactiveX/rxdart) +[![Pub](https://img.shields.io/pub/v/rxdart.svg)](https://pub.dartlang.org/packages/rxdart) +[![Pub Version (including pre-releases)](https://img.shields.io/pub/v/rxdart?include_prereleases&color=%23A0147B)](https://pub.dartlang.org/packages/rxdart) +[![Gitter](https://img.shields.io/gitter/room/ReactiveX/rxdart.svg)](https://gitter.im/ReactiveX/rxdart) +[![Flutter website](https://img.shields.io/badge/flutter-website-deepskyblue.svg)](https://docs.flutter.dev/data-and-backend/state-mgmt/options#bloc--rx) +[![Build Flutter example](https://github.com/ReactiveX/rxdart/actions/workflows/flutter-example.yml/badge.svg)](https://github.com/ReactiveX/rxdart/actions/workflows/flutter-example.yml) +[![License](https://img.shields.io/github/license/ReactiveX/rxdart)](https://www.apache.org/licenses/LICENSE-2.0) +[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2FReactiveX%2Frxdart&count_bg=%23D71092&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) + +## About + +RxDart extends the capabilities of Dart +[Streams](https://api.dart.dev/stable/dart-async/Stream-class.html) and +[StreamControllers](https://api.dart.dev/stable/dart-async/StreamController-class.html). + +Dart comes with a very decent +[Streams](https://api.dart.dev/stable/dart-async/Stream-class.html) API +out-of-the-box; rather than attempting to provide an alternative to this API, +RxDart adds functionality from the reactive extensions specification on top of +it. + +RxDart does not provide its Observable class as a replacement for Dart +Streams. Instead, it offers several additional Stream classes, operators +(extension methods on the Stream class), and Subjects. + +If you are familiar with Observables from other languages, please see [the Rx +Observables vs. Dart Streams comparison chart](#rx-observables-vs-dart-streams) +for notable distinctions between the two. + +## Upgrading from RxDart 0.22.x to 0.23.x + +RxDart 0.23.x moves away from the Observable class, utilizing Dart 2.6's new +extension methods instead. This requires several small refactors that can be +easily automated -- which is just what we've done! + +Please follow the instructions on the +[rxdart_codemod](https://pub.dev/packages/rxdart_codemod) package to +automatically upgrade your code to support RxDart 0.23.x. + +## How To Use RxDart + +### For Example: Reading the Konami Code + +```dart +import 'package:rxdart/rxdart.dart'; + +void main() { + const konamiKeyCodes = [ + KeyCode.UP, + KeyCode.UP, + KeyCode.DOWN, + KeyCode.DOWN, + KeyCode.LEFT, + KeyCode.RIGHT, + KeyCode.LEFT, + KeyCode.RIGHT, + KeyCode.B, + KeyCode.A, + ]; + + final result = querySelector('#result')!; + + document.onKeyUp + .map((event) => event.keyCode) + .bufferCount(10, 1) // An extension method provided by rxdart + .where((lastTenKeyCodes) => const IterableEquality().equals(lastTenKeyCodes, konamiKeyCodes)) + .listen((_) => result.innerHtml = 'KONAMI!'); +} +``` + +## API Overview + +RxDart adds functionality to Dart Streams in three ways: + + * [Stream Classes](#stream-classes) - create Streams with specific capabilities, such as combining or merging many Streams. + * [Extension Methods](#extension-methods) - transform a source Stream into a new Stream with different capabilities, such as throttling or buffering events. + * [Subjects](#subjects) - StreamControllers with additional powers + +### Stream Classes + +The Stream class provides different ways to create a Stream: `Stream.fromIterable` or `Stream.periodic`. RxDart provides additional Stream classes for a variety of tasks, such as combining or merging Streams! + +You can construct the Streams provided by RxDart in two ways. The following examples are equivalent in terms of functionality: + + - Instantiating the Stream class directly. + - Example: `final mergedStream = MergeStream([myFirstStream, mySecondStream]);` + - Using static factories from the Rx class, which are useful for discovering which types of Streams are provided by RxDart. Under the hood, these factories call the corresponding Stream constructor. + - Example: `final mergedStream = Rx.merge([myFirstStream, mySecondStream]);` + +#### List of Classes / Static Factories + +- [CombineLatestStream](https://pub.dev/documentation/rxdart/latest/rx/CombineLatestStream-class.html) (combine2, combine3... combine9) / [Rx.combineLatest2](https://pub.dev/documentation/rxdart/latest/rx/Rx/combineLatest2.html)...[Rx.combineLatest9](https://pub.dev/documentation/rxdart/latest/rx/Rx/combineLatest9.html) +- [ConcatStream](https://pub.dev/documentation/rxdart/latest/rx/ConcatStream-class.html) / [Rx.concat](https://pub.dev/documentation/rxdart/latest/rx/Rx/concat.html) +- [ConcatEagerStream](https://pub.dev/documentation/rxdart/latest/rx/ConcatEagerStream-class.html) / [Rx.concatEager](https://pub.dev/documentation/rxdart/latest/rx/Rx/concatEager.html) +- [DeferStream](https://pub.dev/documentation/rxdart/latest/rx/DeferStream-class.html) / [Rx.defer](https://pub.dev/documentation/rxdart/latest/rx/Rx/defer.html) +- [ForkJoinStream](https://pub.dev/documentation/rxdart/latest/rx/ForkJoinStream-class.html) (join2, join3... join9) / [Rx.forkJoin2](https://pub.dev/documentation/rxdart/latest/rx/Rx/forkJoin2.html)...[Rx.forkJoin9](https://pub.dev/documentation/rxdart/latest/rx/Rx/forkJoin9.html) +- [FromCallableStream](https://pub.dev/documentation/rxdart/latest/rx/FromCallableStream-class.html) / [Rx.fromCallable](https://pub.dev/documentation/rxdart/latest/rx/Rx/fromCallable.html) +- [MergeStream](https://pub.dev/documentation/rxdart/latest/rx/MergeStream-class.html) / [Rx.merge](https://pub.dev/documentation/rxdart/latest/rx/Rx/merge.html) +- [NeverStream](https://pub.dev/documentation/rxdart/latest/rx/NeverStream-class.html) / [Rx.never](https://pub.dev/documentation/rxdart/latest/rx/Rx/never.html) +- [RaceStream](https://pub.dev/documentation/rxdart/latest/rx/RaceStream-class.html) / [Rx.race](https://pub.dev/documentation/rxdart/latest/rx/Rx/race.html) +- [RangeStream](https://pub.dev/documentation/rxdart/latest/rx/RangeStream-class.html) / [Rx.range](https://pub.dev/documentation/rxdart/latest/rx/Rx/range.html) +- [RepeatStream](https://pub.dev/documentation/rxdart/latest/rx/RepeatStream-class.html) / [Rx.repeat](https://pub.dev/documentation/rxdart/latest/rx/Rx/repeat.html) +- [RetryStream](https://pub.dev/documentation/rxdart/latest/rx/RetryStream-class.html) / [Rx.retry](https://pub.dev/documentation/rxdart/latest/rx/Rx/retry.html) +- [RetryWhenStream](https://pub.dev/documentation/rxdart/latest/rx/RetryWhenStream-class.html) / [Rx.retryWhen](https://pub.dev/documentation/rxdart/latest/rx/Rx/retryWhen.html) +- [SequenceEqualStream](https://pub.dev/documentation/rxdart/latest/rx/SequenceEqualStream-class.html) / [Rx.sequenceEqual](https://pub.dev/documentation/rxdart/latest/rx/Rx/sequenceEqual.html) +- [SwitchLatestStream](https://pub.dev/documentation/rxdart/latest/rx/SwitchLatestStream-class.html) / [Rx.switchLatest](https://pub.dev/documentation/rxdart/latest/rx/Rx/switchLatest.html) +- [TimerStream](https://pub.dev/documentation/rxdart/latest/rx/TimerStream-class.html) / [Rx.timer](https://pub.dev/documentation/rxdart/latest/rx/Rx/timer.html) +- [UsingStream](https://pub.dev/documentation/rxdart/latest/rx/UsingStream-class.html) / [Rx.using](https://pub.dev/documentation/rxdart/latest/rx/Rx/using.html) +- [ZipStream](https://pub.dev/documentation/rxdart/latest/rx/ZipStream-class.html) (zip2, zip3, zip4, ..., zip9) / [Rx.zip](https://pub.dev/documentation/rxdart/latest/rx/Rx/zip2.html)...[Rx.zip9](https://pub.dev/documentation/rxdart/latest/rx/Rx/zip9.html) +- If you're looking for an [Interval](https://reactivex.io/documentation/operators/interval.html) equivalent, check out Dart's [Stream.periodic](https://api.dart.dev/stable/2.7.2/dart-async/Stream/Stream.periodic.html) for similar behavior. + +### Extension Methods + +The extension methods provided by RxDart can be used on any `Stream`. They convert a source Stream into a new Stream with additional capabilities, such as buffering or throttling events. + +#### Example + +```dart +Stream.fromIterable([1, 2, 3]) + .throttleTime(Duration(seconds: 1)) + .listen(print); // prints 1 +``` + +#### List of Extension Methods + +- [buffer](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/buffer.html) +- [bufferCount](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferCount.html) +- [bufferTest](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferTest.html) +- [bufferTime](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferTime.html) +- [concatWith](https://pub.dev/documentation/rxdart/latest/rx/ConcatExtensions/concatWith.html) +- [debounce](https://pub.dev/documentation/rxdart/latest/rx/DebounceExtensions/debounce.html) +- [debounceTime](https://pub.dev/documentation/rxdart/latest/rx/DebounceExtensions/debounceTime.html) +- [defaultIfEmpty](https://pub.dev/documentation/rxdart/latest/rx/DefaultIfEmptyExtension/defaultIfEmpty.html) +- [delay](https://pub.dev/documentation/rxdart/latest/rx/DelayExtension/delay.html) +- [delayWhen](https://pub.dev/documentation/rxdart/latest/rx/DelayWhenExtension/delayWhen.html) +- [dematerialize](https://pub.dev/documentation/rxdart/latest/rx/DematerializeExtension/dematerialize.html) +- [distinctUnique](https://pub.dev/documentation/rxdart/latest/rx/DistinctUniqueExtension/distinctUnique.html) +- [doOnCancel](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnCancel.html) +- [doOnData](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnData.html) +- [doOnDone](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnDone.html) +- [doOnEach](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnEach.html) +- [doOnError](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnError.html) +- [doOnListen](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnListen.html) +- [doOnPause](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnPause.html) +- [doOnResume](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnResume.html) +- [endWith](https://pub.dev/documentation/rxdart/latest/rx/EndWithExtension/endWith.html) +- [endWithMany](https://pub.dev/documentation/rxdart/latest/rx/EndWithManyExtension/endWithMany.html) +- [exhaustMap](https://pub.dev/documentation/rxdart/latest/rx/ExhaustMapExtension/exhaustMap.html) +- [flatMap](https://pub.dev/documentation/rxdart/latest/rx/FlatMapExtension/flatMap.html) +- [flatMapIterable](https://pub.dev/documentation/rxdart/latest/rx/FlatMapExtension/flatMapIterable.html) +- [groupBy](https://pub.dev/documentation/rxdart/latest/rx/GroupByExtension/groupBy.html) +- [interval](https://pub.dev/documentation/rxdart/latest/rx/IntervalExtension/interval.html) +- [mapNotNull](https://pub.dev/documentation/rxdart/latest/rx/MapNotNullExtension/mapNotNull.html) +- [mapTo](https://pub.dev/documentation/rxdart/latest/rx/MapToExtension/mapTo.html) +- [materialize](https://pub.dev/documentation/rxdart/latest/rx/MaterializeExtension/materialize.html) +- [max](https://pub.dev/documentation/rxdart/latest/rx/MaxExtension/max.html) +- [mergeWith](https://pub.dev/documentation/rxdart/latest/rx/MergeExtension/mergeWith.html) +- [min](https://pub.dev/documentation/rxdart/latest/rx/MinExtension/min.html) +- [onErrorResume](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorResume.html) +- [onErrorResumeNext](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorResumeNext.html) +- [onErrorReturn](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorReturn.html) +- [onErrorReturnWith](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorReturnWith.html) +- [pairwise](https://pub.dev/documentation/rxdart/latest/rx/PairwiseExtension/pairwise.html) +- [sample](https://pub.dev/documentation/rxdart/latest/rx/SampleExtensions/sample.html) +- [sampleTime](https://pub.dev/documentation/rxdart/latest/rx/SampleExtensions/sampleTime.html) +- [scan](https://pub.dev/documentation/rxdart/latest/rx/ScanExtension/scan.html) +- [skipLast](https://pub.dev/documentation/rxdart/latest/rx/SkipLastExtension/skipLast.html) +- [skipUntil](https://pub.dev/documentation/rxdart/latest/rx/SkipUntilExtension/skipUntil.html) +- [startWith](https://pub.dev/documentation/rxdart/latest/rx/StartWithExtension/startWith.html) +- [startWithMany](https://pub.dev/documentation/rxdart/latest/rx/StartWithManyExtension/startWithMany.html) +- [switchIfEmpty](https://pub.dev/documentation/rxdart/latest/rx/SwitchIfEmptyExtension/switchIfEmpty.html) +- [switchMap](https://pub.dev/documentation/rxdart/latest/rx/SwitchMapExtension/switchMap.html) +- [takeLast](https://pub.dev/documentation/rxdart/latest/rx/TakeLastExtension/takeLast.html) +- [takeUntil](https://pub.dev/documentation/rxdart/latest/rx/TakeUntilExtension/takeUntil.html) +- [takeWhileInclusive](https://pub.dev/documentation/rxdart/latest/rx/TakeWhileInclusiveExtension/takeWhileInclusive.html) +- [throttle](https://pub.dev/documentation/rxdart/latest/rx/ThrottleExtensions/throttle.html) +- [throttleTime](https://pub.dev/documentation/rxdart/latest/rx/ThrottleExtensions/throttleTime.html) +- [timeInterval](https://pub.dev/documentation/rxdart/latest/rx/TimeIntervalExtension/timeInterval.html) +- [timestamp](https://pub.dev/documentation/rxdart/latest/rx/TimeStampExtension/timestamp.html) +- [whereNotNull](https://pub.dev/documentation/rxdart/latest/rx/WhereNotNullExtension/whereNotNull.html) +- [whereType](https://pub.dev/documentation/rxdart/latest/rx/WhereTypeExtension/whereType.html) +- [window](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/window.html) +- [windowCount](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowCount.html) +- [windowTest](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowTest.html) +- [windowTime](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowTime.html) +- [withLatestFrom](https://pub.dev/documentation/rxdart/latest/rx/WithLatestFromExtensions.html) +- [zipWith](https://pub.dev/documentation/rxdart/latest/rx/ZipWithExtension/zipWith.html) + +### Subjects + +Dart provides the [StreamController](https://api.dart.dev/stable/dart-async/StreamController-class.html) class to create and manage a Stream. RxDart offers two additional StreamControllers with additional capabilities, known as Subjects: + +- [BehaviorSubject](https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html) - A broadcast StreamController that caches the latest added value or error. When a new listener subscribes to the Stream, the latest value or error will be emitted to the listener. Furthermore, you can synchronously read the last emitted value. +- [ReplaySubject](https://pub.dev/documentation/rxdart/latest/rx/ReplaySubject-class.html) - A broadcast StreamController that caches the added values. When a new listener subscribes to the Stream, the cached values will be emitted to the listener. + +## Rx Observables vs Dart Streams + +In many situations, Streams and Observables work the same way. However, if you're used to standard Rx Observables, some features of the Stream API may surprise you. We've included a table below to help folks understand the differences. + +Additional information about the following situations can be found by reading the [Rx class documentation](https://pub.dev/documentation/rxdart/latest/rx/Rx-class.html). + +| Situation | Rx Observables | Dart Streams | +| ------------- |------------- | ------------- | +| An error is raised | Observable Terminates with Error | Error is emitted and Stream continues | +| Cold Observables | Multiple subscribers can listen to the same cold Observable, and each subscription will receive a unique Stream of data | Single subscriber only | +| Hot Observables | Yes | Yes, known as Broadcast Streams | +| Is {Publish, Behavior, Replay}Subject hot? | Yes | Yes | +| Single/Maybe/Completable ? | Yes | Yes, uses [rxdart_ext Single](https://pub.dev/documentation/rxdart_ext/latest/rxdart_ext/Single-class.html) (`Completable == Single` and `Maybe == Single`) | +| Support back pressure| Yes | Yes | +| Can emit null? | Yes, except RxJava | Yes | +| Sync by default | Yes | No | +| Can pause/resume a subscription*? | No | Yes | + +## Examples + +Web and command-line examples can be found in the `example` folder. + +### Web Examples + +In order to run the web examples, please follow these steps: + + 1. Clone this repo and enter the directory `examples/web` + 2. Run `dart pub get` + 3. Run `dart pub global activate webdev` + 4. Run `webdev serve` + 5. Navigate to http://localhost:8080/ in your browser + +### Command Line Examples + +In order to run the command line example, please follow these steps: + + 1. Clone this repo and enter the directory + 2. Run `pub get` + 3. Run `dart examples/fibonacci/lib/example.dart 10` + +### Flutter Example + +#### Install Flutter + +To run the flutter example, you must have Flutter installed. For installation instructions, view the online +[documentation](https://flutter.io/). + +#### Run the app + + 1. Open up an Android Emulator, the iOS Simulator, or connect an appropriate mobile device for debugging. + 2. Open up a terminal + 3. `cd` into the `examples/flutter/github_search` directory + 4. Run `flutter doctor` to ensure you have all Flutter dependencies working. + 5. Run `flutter packages get` + 6. Run `flutter run` + +## Notable References + +- [Documentation on the Dart Stream class](https://api.dart.dev/stable/dart-async/Stream-class.html) +- [Tutorial on working with Streams in Dart](https://www.dartlang.org/tutorials/language/streams) +- [ReactiveX (Rx)](https://reactivex.io/) + +## Changelog + +Refer to the [Changelog](https://github.com/ReactiveX/rxdart/blob/master/packages/rxdart/CHANGELOG.md) to get all release notes. + +## Extensions + +Check out [rxdart_ext](https://pub.dev/packages/rxdart_ext), which provides many extension methods and classes built on top of RxDart. + + diff --git a/sandbox/reactivex/analysis_options.yaml b/sandbox/reactivex/analysis_options.yaml new file mode 100644 index 0000000..6f808f6 --- /dev/null +++ b/sandbox/reactivex/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + +linter: + rules: + - public_member_api_docs + - always_declare_return_types # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - prefer_single_quotes # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - unawaited_futures # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - unsafe_html # https://github.com/dart-lang/lints#migrating-from-packagepedantic diff --git a/sandbox/reactivex/lib/angel3_reactivex.dart b/sandbox/reactivex/lib/angel3_reactivex.dart new file mode 100644 index 0000000..2003c7a --- /dev/null +++ b/sandbox/reactivex/lib/angel3_reactivex.dart @@ -0,0 +1,7 @@ +library rx; + +export 'src/rx.dart'; +export 'streams.dart'; +export 'subjects.dart'; +export 'transformers.dart'; +export 'utils.dart'; diff --git a/sandbox/reactivex/lib/src/rx.dart b/sandbox/reactivex/lib/src/rx.dart new file mode 100644 index 0000000..113180e --- /dev/null +++ b/sandbox/reactivex/lib/src/rx.dart @@ -0,0 +1,1357 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/streams.dart'; + +/// A utility class that provides static methods to create the various Streams +/// provided by angel3_reactivex. +/// +/// ### Example +/// +/// Rx.combineLatest([ +/// Stream.value('a'), +/// Stream.fromIterable(['b', 'c', 'd']) +/// ], (list) => list.join()) +/// .listen(print); // prints 'ab', 'ac', 'ad' +/// +/// ### Learning angel3_reactivex +/// +/// This library contains documentation and examples for each method. In +/// addition, more complex examples can be found in the +/// [angel3_reactivex github repo](https://github.com/ReactiveX/angel3_reactivex) demonstrating how +/// to use angel3_reactivex with web, command line, and Flutter applications. +/// +/// #### Additional Resources +/// +/// In addition to the angel3_reactivex documentation and examples, you can find many +/// more articles on Dart Streams that teach the fundamentals upon which +/// angel3_reactivex is built. +/// +/// - [Asynchronous Programming: Streams](https://www.dartlang.org/tutorials/language/streams) +/// - [Single-Subscription vs. Broadcast Streams](https://dart.dev/tutorials/language/streams#two-kinds-of-streams) +/// - [Creating Streams in Dart](https://www.dartlang.org/articles/libraries/creating-streams) +/// - [Testing Streams: Stream Matchers](https://pub.dartlang.org/packages/test#stream-matchers) +/// +/// ### Dart Streams vs Traditional Rx Observables +/// In ReactiveX, the Observable class is the heart of the ecosystem. +/// Observables represent data sources that emit 'items' or 'events' over time. +/// Dart already includes such a data source: Streams. +/// +/// In order to integrate fluently with the Dart ecosystem, Rx Dart does not +/// provide a [Stream] class, but rather adds functionality to Dart Streams. +/// This provides several advantages: +/// +/// - angel3_reactivex works with any API that expects a Dart Stream as an input. +/// - No need to implement or replace the many methods and properties from the core Stream API. +/// - Ability to create Streams with language-level syntax. +/// +/// Overall, we attempt to follow the ReactiveX spec as closely as we can, but +/// prioritize fitting in with the Dart ecosystem when a trade-off must be made. +/// Therefore, there are some important differences to note between Dart's +/// [Stream] class and standard Rx `Observable`. +/// +/// First, Cold Observables exist in Dart as normal Streams, but they are +/// single-subscription only. In other words, you can only listen a Stream +/// once, unless it is a hot (aka broadcast) Stream. If you attempt to listen to +/// a cold Stream twice, a StateError will be thrown. If you need to listen to a +/// stream multiple times, you can simply create a factory function that returns +/// a new instance of the stream. +/// +/// Second, many methods contained within, such as `first` and `last` do not +/// return a `Single` nor an `Observable`, but rather must return a Dart Future. +/// Luckily, Dart's `Future` class is conceptually similar to `Single`, and can +/// be easily converted back to a Stream using the `myFuture.asStream()` method +/// if needed. +/// +/// Third, Streams in Dart do not close by default when an error occurs. In Rx, +/// an Error causes the Observable to terminate unless it is intercepted by +/// an operator. Dart has mechanisms for creating streams that close when an +/// error occurs, but the majority of Streams do not exhibit this behavior. +/// +/// Fourth, Dart streams are asynchronous by default, whereas Observables are +/// synchronous by default, unless you schedule work on a different Scheduler. +/// You can create synchronous Streams with Dart, but please be aware the the +/// default is simply different. +/// +/// Finally, when using Dart Broadcast Streams (similar to Hot Observables), +/// please know that `onListen` will only be called the first time the +/// broadcast stream is listened to. +abstract class Rx { + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an item. + /// This is helpful when you need to combine a dynamic number of Streams. + /// + /// The Stream will not emit any lists of values until all of the source + /// streams have emitted at least one value. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the combiner function. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest([ + /// Stream.value('a'), + /// Stream.fromIterable(['b', 'c', 'd']) + /// ], (list) => list.join()) + /// .listen(print); // prints 'ab', 'ac', 'ad' + static Stream combineLatest( + Iterable> streams, R Function(List values) combiner) => + CombineLatestStream(streams, combiner); + + /// Merges the given Streams into a single Stream that emits a List of the + /// values emitted by the source Stream. This is helpful when you need to + /// combine a dynamic number of Streams. + /// + /// The Stream will not emit any lists of values until all of the source + /// streams have emitted at least one value. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatestList([ + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// ]) + /// .listen(print); // prints [1, 0], [1, 1], [1, 2] + static Stream> combineLatestList(Iterable> streams) => + CombineLatestStream.list(streams); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest2( + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// (a, b) => a + b) + /// .listen(print); //prints 1, 2, 3 + static Stream combineLatest2(Stream streamA, Stream streamB, + T Function(A a, B b) combiner) => + CombineLatestStream.combine2(streamA, streamB, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'c']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abc', 'abc' + static Stream combineLatest3( + Stream streamA, + Stream streamB, + Stream streamC, + T Function(A a, B b, C c) combiner) => + CombineLatestStream.combine3(streamA, streamB, streamC, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'd']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abcd', 'abcd' + static Stream combineLatest4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) combiner) => + CombineLatestStream.combine4( + streamA, streamB, streamC, streamD, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'e']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcde', 'abcde' + static Stream combineLatest5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) combiner) => + CombineLatestStream.combine5( + streamA, streamB, streamC, streamD, streamE, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'f']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdef', 'abcdef' + static Stream combineLatest6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) combiner) => + CombineLatestStream.combine6( + streamA, streamB, streamC, streamD, streamE, streamF, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'g']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefg', 'abcdefg' + static Stream combineLatest7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) combiner) => + CombineLatestStream.combine7(streamA, streamB, streamC, streamD, streamE, + streamF, streamG, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'h']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgh', 'abcdefgh' + static Stream combineLatest8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner) => + CombineLatestStream.combine8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + combiner, + ); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'i']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghi', 'abcdefghi' + static Stream combineLatest9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner) => + CombineLatestStream.combine9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + combiner, + ); + + /// Concatenates all of the specified stream sequences, as long as the + /// previous stream sequence terminated successfully. + /// + /// It does this by subscribing to each stream one by one, emitting all items + /// and completing before subscribing to the next stream. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#concat) + /// + /// ### Example + /// + /// Rx.concat([ + /// Stream.value(1), + /// Rx.timer(2, Duration(days: 1)), + /// Stream.value(3) + /// ]) + /// .listen(print); // prints 1, 2, 3 + static Stream concat(Iterable> streams) => + ConcatStream(streams); + + /// Concatenates all of the specified stream sequences, as long as the + /// previous stream sequence terminated successfully. + /// + /// In the case of concatEager, rather than subscribing to one stream after + /// the next, all streams are immediately subscribed to. The events are then + /// captured and emitted at the correct time, after the previous stream has + /// finished emitting items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#concat) + /// + /// ### Example + /// + /// Rx.concatEager([ + /// Stream.value(1), + /// Rx.timer(2, Duration(days: 1)), + /// Stream.value(3) + /// ]) + /// .listen(print); // prints 1, 2, 3 + static Stream concatEager(Iterable> streams) => + ConcatEagerStream(streams); + + /// The defer factory waits until an observer subscribes to it, and then it + /// creates a [Stream] with the given factory function. + /// + /// In some circumstances, waiting until the last minute (that is, until + /// subscription time) to generate the Stream can ensure that this + /// Stream contains the freshest data. + /// + /// By default, DeferStreams are single-subscription. However, it's possible + /// to make them reusable. + /// + /// ### Example + /// + /// Rx.defer(() => Stream.value(1)) + /// .listen(print); //prints 1 + static Stream defer(Stream Function() streamFactory, + {bool reusable = false}) => + DeferStream(streamFactory, reusable: reusable); + + /// Creates a [Stream] where all last events of existing stream(s) are piped + /// through a sink-transformation. + /// + /// This operator is best used when you have a group of streams + /// and only care about the final emitted value of each. + /// One common use case for this is if you wish to issue multiple + /// requests on page load (or some other event) + /// and only want to take action when a response has been received for all. + /// + /// In this way it is similar to how you might use [Future.wait]. + /// + /// Be aware that if any of the inner streams supplied to forkJoin error + /// you will lose the value of any other streams that would or have already + /// completed if you do not catch the error correctly on the inner stream. + /// + /// If you are only concerned with all inner streams completing + /// successfully you can catch the error on the outside. + /// It's also worth noting that if you have an stream + /// that emits more than one item, and you are concerned with the previous + /// emissions forkJoin is not the correct choice. + /// + /// In these cases you may better off with an operator like combineLatest or zip. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the combiner function. + /// + /// ### Example + /// + /// Rx.forkJoin([ + /// Stream.value('a'), + /// Stream.fromIterable(['b', 'c', 'd']) + /// ], (list) => list.join(', ')) + /// .listen(print); // prints 'a, d' + static Stream forkJoin( + Iterable> streams, R Function(List values) combiner) => + ForkJoinStream(streams, combiner); + + /// Merges the given Streams into a single Stream that emits a List of the + /// last values emitted by the source stream(s). This is helpful when you need to + /// forkJoin a dynamic number of Streams. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// ### Example + /// + /// Rx.forkJoinList([ + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// ]) + /// .listen(print); // prints [1, 2] + static Stream> forkJoinList(Iterable> streams) => + ForkJoinStream.list(streams); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin2( + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// (a, b) => a + b) + /// .listen(print); //prints 3 + static Stream forkJoin2(Stream streamA, Stream streamB, + T Function(A a, B b) combiner) => + ForkJoinStream.join2(streamA, streamB, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'd']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abd' + static Stream forkJoin3(Stream streamA, Stream streamB, + Stream streamC, T Function(A a, B b, C c) combiner) => + ForkJoinStream.join3(streamA, streamB, streamC, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'e']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abce' + static Stream forkJoin4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) combiner) => + ForkJoinStream.join4(streamA, streamB, streamC, streamD, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'f']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcdf' + static Stream forkJoin5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) combiner) => + ForkJoinStream.join5( + streamA, streamB, streamC, streamD, streamE, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'g']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdeg' + static Stream forkJoin6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) combiner) => + ForkJoinStream.join6( + streamA, streamB, streamC, streamD, streamE, streamF, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'h']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefh' + static Stream forkJoin7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) combiner) => + ForkJoinStream.join7(streamA, streamB, streamC, streamD, streamE, streamF, + streamG, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'i']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgi' + static Stream forkJoin8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner) => + ForkJoinStream.join8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + combiner, + ); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'j']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghj' + static Stream forkJoin9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner) => + ForkJoinStream.join9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + combiner, + ); + + /// Returns a Stream that, when listening to it, calls a function you specify + /// and then emits the value returned from that function. + /// + /// If result from invoking [callable] function: + /// - Is a [Future]: when the future completes, this stream will fire one event, either + /// data or error, and then close with a done-event. + /// - Is a [T]: this stream emits a single data event and then completes with a done event. + /// + /// By default, a [FromCallableStream] is a single-subscription Stream. However, it's possible + /// to make them reusable. + /// This Stream is effectively equivalent to one created by + /// `(() async* { yield await callable() }())` or `(() async* { yield callable(); }())`. + /// + /// [ReactiveX doc](http://reactivex.io/documentation/operators/from.html) + /// + /// ### Example + /// + /// Rx.fromCallable(() => 'Value').listen(print); // prints Value + /// + /// Rx.fromCallable(() async { + /// await Future.delayed(const Duration(seconds: 1)); + /// return 'Value'; + /// }).listen(print); // prints Value + static Stream fromCallable(FutureOr Function() callable, + {bool reusable = false}) => + FromCallableStream(callable, reusable: reusable); + + /// Flattens the items emitted by the given [streams] into a single Stream + /// sequence. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#merge) + /// + /// ### Example + /// + /// Rx.merge([ + /// Rx.timer(1, Duration(days: 10)), + /// Stream.value(2) + /// ]) + /// .listen(print); // prints 2, 1 + static Stream merge(Iterable> streams) => + MergeStream(streams); + + /// Returns a non-terminating stream sequence, which can be used to denote + /// an infinite duration. + /// + /// The never operator is one with very specific and limited behavior. These + /// are useful for testing purposes, and sometimes also for combining with + /// other Streams or as parameters to operators that expect other + /// Streams as parameters. + /// + /// ### Example + /// + /// Rx.never().listen(print); // Neither prints nor terminates + static Stream never() => NeverStream(); + + /// Given two or more source [streams], emit all of the items from only + /// the first of these [streams] to emit an item or notification. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#amb) + /// + /// ### Example + /// + /// Rx.race([ + /// Rx.timer(1, Duration(days: 1)), + /// Rx.timer(2, Duration(days: 2)), + /// Rx.timer(3, Duration(seconds: 1)) + /// ]).listen(print); // prints 3 + static Stream race(Iterable> streams) => + RaceStream(streams); + + /// Returns a [Stream] that emits a sequence of Integers within a specified + /// range. + /// + /// ### Example + /// + /// Rx.range(1, 3).listen((i) => print(i)); // Prints 1, 2, 3 + /// + /// Rx.range(3, 1).listen((i) => print(i)); // Prints 3, 2, 1 + static Stream range(int startInclusive, int endInclusive) => + RangeStream(startInclusive, endInclusive); + + /// Creates a [Stream] that will recreate and re-listen to the source + /// Stream the specified number of times until the [Stream] terminates + /// successfully. + /// + /// If [count] is not specified, it repeats indefinitely. + /// + /// ### Example + /// + /// RepeatStream((int repeatCount) => + /// Stream.value('repeat index: $repeatCount'), 3) + /// .listen((i) => print(i)); // Prints 'repeat index: 0, repeat index: 1, repeat index: 2' + static Stream repeat(Stream Function(int repeatIndex) streamFactory, + [int? count]) => + RepeatStream(streamFactory, count); + + /// Creates a [Stream] that will recreate and re-listen to the source + /// Stream the specified number of times until the Stream terminates + /// successfully. + /// + /// If the retry count is not specified, it retries indefinitely. If the retry + /// count is met, but the Stream has not terminated successfully, all of the errors + /// and StackTraces that caused the failure will be emitted. + /// + /// ### Example + /// + /// Rx.retry(() => Stream.value(1)) + /// .listen((i) => print(i)); // Prints 1 + /// + /// Rx.retry( + /// () => Stream.value(1).concatWith([Stream.error(Error())]), + /// 1, + /// ).listen( + /// print, + /// onError: (Object e, StackTrace s) => print(e), + /// ); // Prints 1, 1, Instance of 'Error', Instance of 'Error' + static Stream retry(Stream Function() streamFactory, [int? count]) => + RetryStream(streamFactory, count); + + /// Creates a Stream that will recreate and re-listen to the source + /// Stream when the notifier emits a new value. If the source Stream + /// emits an error or it completes, the Stream terminates. + /// + /// If the [retryWhenFactory] throws an error or returns a Stream that emits an error, + /// original error will be emitted. And then, the error from [retryWhenFactory] will be emitted + /// if it is not identical with original error. + /// + /// ### Basic Example + /// + /// ```dart + /// Rx.retryWhen( + /// () => Stream.fromIterable([1]), + /// (Object error, StackTrace s) => throw error, + /// ).listen(print); // Prints 1 + /// ``` + /// + /// ### Periodic Example + /// + /// ```dart + /// Rx.retryWhen( + /// () => Stream.periodic(const Duration(seconds: 1), (int i) => i) + /// .map((int i) => i == 2 ? throw 'exception' : i), + /// (Object e, StackTrace s) => + /// Rx.timer(null, const Duration(milliseconds: 200)), + /// ).take(4).listen(print); // Prints 0, 1, 0, 1 + /// ``` + /// + /// ### Complex Example + /// + /// ```dart + /// var errorHappened = false; + /// Rx.retryWhen( + /// () => Stream.periodic(const Duration(seconds: 1), (i) => i).map((i) { + /// if (i == 3 && !errorHappened) { + /// throw 'We can take this. Please restart.'; + /// } else if (i == 4) { + /// throw 'It\'s enough.'; + /// } else { + /// return i; + /// } + /// }), + /// (e, s) { + /// errorHappened = true; + /// if (e == 'We can take this. Please restart.') { + /// return Stream.value('Ok. Here you go!'); + /// } else { + /// return Stream.error(e, s); + /// } + /// }, + /// ).listen(print, onError: print); // Prints 0, 1, 2, 0, 1, 2, 3, It's enough. + /// ``` + static Stream retryWhen( + Stream Function() streamFactory, + Stream Function(Object error, StackTrace stackTrace) retryWhenFactory, + ) => + RetryWhenStream(streamFactory, retryWhenFactory); + + /// Determine whether two Streams emit the same sequence of items. + /// You can provide an optional [equals] handler to determine equality. + /// + /// [Interactive marble diagram](https://rxmarbles.com/#sequenceEqual) + /// + /// ### Example + /// + /// Rx.sequenceEqual([ + /// Stream.fromIterable([1, 2, 3, 4, 5]), + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// ]) + /// .listen(print); // prints true + static Stream sequenceEqual( + Stream stream, + Stream other, { + bool Function(A a, B b)? equals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + }) => + SequenceEqualStream( + stream, + other, + dataEquals: equals, + errorEquals: errorEquals, + ); + + /// Convert a Stream that emits Streams (aka a 'Higher Order Stream') into a + /// single Stream that emits the items emitted by the most-recently-emitted of + /// those Streams. + /// + /// This Stream will unsubscribe from the previously-emitted Stream when + /// a new Stream is emitted from the source Stream and subscribe to the new + /// Stream. + /// + /// ### Example + /// + /// ```dart + /// final switchLatestStream = SwitchLatestStream( + /// Stream.fromIterable(>[ + /// Rx.timer('A', Duration(seconds: 2)), + /// Rx.timer('B', Duration(seconds: 1)), + /// Stream.value('C'), + /// ]), + /// ); + /// + /// // Since the first two Streams do not emit data for 1-2 seconds, and the + /// // 3rd Stream will be emitted before that time, only data from the 3rd + /// // Stream will be emitted to the listener. + /// switchLatestStream.listen(print); // prints 'C' + /// ``` + static Stream switchLatest(Stream> streams) => + SwitchLatestStream(streams); + + /// Emits the given value after a specified amount of time. + /// + /// ### Example + /// + /// Rx.timer('hi', Duration(minutes: 1)) + /// .listen((i) => print(i)); // print 'hi' after 1 minute + static Stream timer(T value, Duration duration) => + TimerStream(value, duration); + + /// When listener listens to it, creates a resource object from resource factory function, + /// and creates a [Stream] from the given factory function and resource as argument. + /// Finally when the stream finishes emitting items or stream subscription + /// is cancelled (call [StreamSubscription.cancel] or `Stream.listen(cancelOnError: true)`), + /// call the disposer function on resource object. + /// + /// The [UsingStream] is a way you can instruct an Stream to create + /// a resource that exists only during the lifespan of the Stream + /// and is disposed of when the Stream terminates. + /// + /// [Marble diagram](http://reactivex.io/documentation/operators/images/using.c.png) + /// + /// ### Example + /// + /// Rx.using>( + /// resourceFactory: () => Queue.of([1, 2, 3]), + /// streamFactory: (r) => Stream.fromIterable(r), + /// disposer: (r) => r.clear(), + /// ).listen(print); // prints 1, 2, 3 + static Stream using({ + required FutureOr Function() resourceFactory, + required Stream Function(R) streamFactory, + required FutureOr Function(R) disposer, + }) => + UsingStream( + resourceFactory: resourceFactory, + streamFactory: streamFactory, + disposer: disposer, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip2( + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// (a, b) => a + b) + /// .listen(print); // prints 'Hi Friend' + static Stream zip2( + Stream streamA, Stream streamB, T Function(A a, B b) zipper) => + ZipStream.zip2(streamA, streamB, zipper); + + /// Merges the iterable streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the zipper function. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip( + /// [ + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// ], + /// (values) => values.first + values.last + /// ) + /// .listen(print); // prints 'Hi Friend' + static Stream zip( + Iterable> streams, R Function(List values) zipper) => + ZipStream(streams, zipper); + + /// Merges the iterable streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zipList( + /// [ + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// ], + /// ) + /// .listen(print); // prints ['Hi ', 'Friend'] + static Stream> zipList(Iterable> streams) => + ZipStream.list(streams); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'dropped']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abc' + static Stream zip3(Stream streamA, Stream streamB, + Stream streamC, T Function(A a, B b, C c) zipper) => + ZipStream.zip3(streamA, streamB, streamC, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'dropped']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abcd' + static Stream zip4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) zipper) => + ZipStream.zip4(streamA, streamB, streamC, streamD, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'dropped']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcde' + static Stream zip5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) zipper) => + ZipStream.zip5(streamA, streamB, streamC, streamD, streamE, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'dropped']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdef' + static Stream zip6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) zipper) => + ZipStream.zip6( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'dropped']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefg' + static Stream zip7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) zipper) => + ZipStream.zip7( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'dropped']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgh' + static Stream zip8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) zipper) => + ZipStream.zip8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'dropped']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghi' + static Stream zip9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) zipper) => + ZipStream.zip9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + zipper, + ); +} diff --git a/sandbox/reactivex/lib/src/streams/combine_latest.dart b/sandbox/reactivex/lib/src/streams/combine_latest.dart new file mode 100644 index 0000000..9ed4331 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/combine_latest.dart @@ -0,0 +1,352 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Merges the given Streams into one Stream sequence by using the +/// combiner function whenever any of the source stream sequences emits an +/// item. +/// +/// The Stream will not emit until all Streams have emitted at least one +/// item. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the combiner function. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) +/// +/// ### Basic Example +/// +/// This constructor takes in an `Iterable>` and outputs a +/// `Stream>` whenever any of the values change from the source +/// stream. This is useful with a dynamic number of source streams! +/// +/// CombineLatestStream.list([ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D'])]) +/// .listen(print); //prints ['a', 'b', 'C'], ['a', 'b', 'D'] +/// +/// ### Example with combiner +/// +/// If you wish to combine the list of values into a new object before you +/// +/// CombineLatestStream( +/// [ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']) +/// ], +/// (values) => values.last +/// ) +/// .listen(print); //prints 'C', 'D' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to combine a specific number of Streams together with proper +/// types information for the value of each Stream, use the +/// [combine2] - [combine9] operators. +/// +/// CombineLatestStream.combine2( +/// Stream.fromIterable([1]), +/// Stream.fromIterable([2, 3]), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 3, 4 +class CombineLatestStream extends StreamView { + /// Constructs a [Stream] that observes an [Iterable] of [Stream] + /// and builds a [List] containing all latest events emitted by the provided [Iterable] of [Stream]. + /// The [combiner] maps this [List] into a new event of type [R] + CombineLatestStream( + Iterable> streams, + R Function(List values) combiner, + ) : super(_buildController(streams, combiner).stream); + + /// Constructs a [CombineLatestStream] using a default combiner, which simply + /// yields a [List] of all latest events emitted by the provided [Iterable] of [Stream]. + static CombineLatestStream> list( + Iterable> streams, + ) => + CombineLatestStream>( + streams, + (List values) => values, + ); + + /// Constructs a [CombineLatestStream] from a pair of [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) combiner, + ) => + CombineLatestStream( + [streamOne, streamTwo], + (List values) => combiner(values[0] as A, values[1] as B), + ); + + /// Constructs a [CombineLatestStream] from 3 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 4 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 5 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 6 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 7 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 8 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner, + ) => + CombineLatestStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 9 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner, + ) => + CombineLatestStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + static StreamController _buildController( + Iterable> streams, + R Function(List values) combiner, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + List? values; + + controller.onListen = () { + var triggered = 0, completed = 0; + + void onDone() { + if (++completed == subscriptions.length) { + controller.close(); + } + } + + subscriptions = streams.mapIndexed((index, stream) { + var hasFirstEvent = false; + + return stream.listen( + (T value) { + if (values == null) { + return; + } + + values![index] = value; + + if (!hasFirstEvent) { + hasFirstEvent = true; + triggered++; + } + + if (triggered == subscriptions.length) { + final R combined; + try { + combined = combiner(List.unmodifiable(values!)); + } catch (e, s) { + controller.addError(e, s); + return; + } + controller.add(combined); + } + }, + onError: controller.addError, + onDone: onDone, + ); + }).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + values = List.filled(subscriptions.length, null); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () { + values = null; + return subscriptions.cancelAll(); + }; + + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/concat.dart b/sandbox/reactivex/lib/src/streams/concat.dart new file mode 100644 index 0000000..a451e6a --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/concat.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +/// Concatenates all of the specified stream sequences, as long as the +/// previous stream sequence terminated successfully. +/// +/// It does this by subscribing to each stream one by one, emitting all items +/// and completing before subscribing to the next stream. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#concat) +/// +/// ### Example +/// +/// ConcatStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(days: 1)), +/// Stream.fromIterable([3]) +/// ]) +/// .listen(print); // prints 1, 2, 3 +class ConcatStream extends StreamView { + /// Constructs a [Stream] which emits all events from [streams]. + /// The [Iterable] is traversed upwards, meaning that the current first + /// [Stream] in the [Iterable] needs to complete, before events from the + /// next [Stream] will be subscribed to. + ConcatStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + StreamSubscription? subscription; + + controller.onListen = () { + final iterator = streams.iterator; + + void moveNext() { + if (!iterator.moveNext()) { + controller.close(); + return; + } + subscription?.cancel(); + subscription = iterator.current.listen(controller.add, + onError: controller.addError, onDone: moveNext); + } + + moveNext(); + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + controller.onCancel = () => subscription?.cancel(); + + return controller; + } +} + +/// Extends the Stream class with the ability to concatenate one stream with +/// another. +extension ConcatExtensions on Stream { + /// Returns a Stream that emits all items from the current Stream, + /// then emits all items from the given streams, one after the next. + /// + /// ### Example + /// + /// TimerStream(1, Duration(seconds: 10)) + /// .concatWith([Stream.fromIterable([2])]) + /// .listen(print); // prints 1, 2 + Stream concatWith(Iterable> other) { + final concatStream = ConcatStream([this, ...other]); + + return isBroadcast + ? concatStream.asBroadcastStream(onCancel: (s) => s.cancel()) + : concatStream; + } +} diff --git a/sandbox/reactivex/lib/src/streams/concat_eager.dart b/sandbox/reactivex/lib/src/streams/concat_eager.dart new file mode 100644 index 0000000..17beddd --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/concat_eager.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/concat.dart'; +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Concatenates all of the specified stream sequences, as long as the +/// previous stream sequence terminated successfully. +/// +/// In the case of concatEager, rather than subscribing to one stream after +/// the next, all streams are immediately subscribed to. The events are then +/// captured and emitted at the correct time, after the previous stream has +/// finished emitting items. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#concat) +/// +/// ### Example +/// +/// ConcatEagerStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(days: 1)), +/// Stream.fromIterable([3]) +/// ]) +/// .listen(print); // prints 1, 2, 3 +class ConcatEagerStream extends StreamView { + /// Constructs a [Stream] which emits all events from [streams]. + /// Unlike [ConcatStream], all [Stream]s inside [streams] are + /// immediately subscribed to and events captured at the correct time, + /// but emitted only after the previous [Stream] in [streams] is + /// successfully closed. + ConcatEagerStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + StreamSubscription? activeSubscription; + + controller.onListen = () { + final completeEvents = >[]; + + void Function() onDone(int index) { + return () { + if (index < subscriptions.length - 1) { + completeEvents[index].complete(); + activeSubscription = subscriptions[index + 1]; + } else if (index == subscriptions.length - 1) { + controller.close(); + } + }; + } + + StreamSubscription createSubscription(int index, Stream stream) { + final subscription = stream.listen(controller.add, + onError: controller.addError, onDone: onDone(index)); + + // pause all subscriptions, except the first, initially + if (index > 0) { + final completer = Completer.sync(); + completeEvents.add(completer); + subscription.pause(completer.future); + } + + return subscription; + } + + subscriptions = + streams.mapIndexed(createSubscription).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + // initially, the very first subscription is the active one + activeSubscription = subscriptions.first; + } + }; + controller.onPause = () => activeSubscription?.pause(); + controller.onResume = () => activeSubscription?.resume(); + controller.onCancel = () { + activeSubscription = null; + return subscriptions.cancelAll(); + }; + + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/connectable_stream.dart b/sandbox/reactivex/lib/src/streams/connectable_stream.dart new file mode 100644 index 0000000..bf4a5cb --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/connectable_stream.dart @@ -0,0 +1,516 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/replay_stream.dart'; +import 'package:angel3_reactivex/src/streams/value_stream.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; +import 'package:angel3_reactivex/subjects.dart'; + +/// A ConnectableStream resembles an ordinary Stream, except that it +/// can be listened to multiple times and does not begin emitting items when +/// it is listened to, but only when its [connect] method is called. +/// +/// This class can be used to broadcast a single-subscription Stream, and +/// can be used to wait for all intended Observers to [listen] to the +/// Stream before it begins emitting items. +abstract class ConnectableStream extends StreamView { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called. + ConnectableStream(Stream stream) : super(stream); + + /// Returns a [Stream] that automatically connects (at most once) to this + /// ConnectableStream when the first Observer subscribes. + /// + /// To disconnect from the source Stream, provide a [connection] callback and + /// cancel the `subscription` at the appropriate time. + Stream autoConnect({ + void Function(StreamSubscription subscription) connection, + }); + + /// Instructs the [ConnectableStream] to begin emitting items from the + /// source Stream. To disconnect from the source stream, cancel the + /// subscription. + StreamSubscription connect(); + + /// Returns a [Stream] that stays connected to this ConnectableStream + /// as long as there is at least one subscription to this + /// ConnectableStream. + Stream refCount(); +} + +enum _ConnectableStreamUse { + autoConnect, + connect, + refCount, +} + +/// Base class for implementations of [ConnectableStream]. +/// [S] is type of the forwarding [Subject]. +/// [R] is return type of [autoConnect] and [refCount] (type constraint: `S extends R`). +abstract class AbstractConnectableStream, + R extends Stream> extends ConnectableStream { + final Stream _source; + final S _subject; + _ConnectableStreamUse? _use; + + /// Constructs a [AbstractConnectableStream] with a source [Stream] and the forwarding [Subject]. + AbstractConnectableStream( + Stream source, + S subject, + ) : assert(subject is R), + _source = source, + _subject = subject, + super(subject); + + late final _connection = ConnectableStreamSubscription( + _source.listen( + _subject.add, + onError: _subject.addError, + onDone: _subject.close, + ), + _subject, + ); + + bool _canReuse(_ConnectableStreamUse use) { + if (_use != null && _use != use) { + throw StateError( + 'Do not mix autoConnect, connect and refCount together, you should only use one of them!'); + } + + final canReuse = _use != null && _use == use; + _use = use; + return canReuse; + } + + @override + R autoConnect({ + void Function(StreamSubscription subscription)? connection, + }) { + if (_canReuse(_ConnectableStreamUse.autoConnect)) { + return _subject as R; + } + + _subject.onListen = () { + final subscription = _connection; + connection?.call(subscription); + }; + _subject.onCancel = null; + + return _subject as R; + } + + @override + StreamSubscription connect() { + if (_canReuse(_ConnectableStreamUse.connect)) { + return _connection; + } + + _subject.onListen = _subject.onCancel = null; + return _connection; + } + + @override + R refCount() { + if (_canReuse(_ConnectableStreamUse.refCount)) { + return _subject as R; + } + + StreamSubscription? subscription; + _subject.onListen = () => subscription = _connection; + _subject.onCancel = () => subscription?.cancel(); + + return _subject as R; + } +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast [Stream]. +class PublishConnectableStream + extends AbstractConnectableStream, Stream> { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [PublishSubject]. + PublishConnectableStream(Stream source, {bool sync = false}) + : super(source, PublishSubject(sync: sync)); +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast Stream that replays the latest value to any new listener, and +/// provides synchronous access to the latest emitted value. +class ValueConnectableStream + extends AbstractConnectableStream, ValueStream> + implements ValueStream { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [BehaviorSubject]. + ValueConnectableStream(Stream source, {bool sync = false}) + : super(source, BehaviorSubject(sync: sync)); + + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [BehaviorSubject.seeded]. + ValueConnectableStream.seeded(Stream source, T seedValue, + {bool sync = false}) + : super(source, BehaviorSubject.seeded(seedValue, sync: sync)); + + @override + bool get hasValue => _subject.hasValue; + + @override + T get value => _subject.value; + + @override + T? get valueOrNull => _subject.valueOrNull; + + @override + Object get error => _subject.error; + + @override + Object? get errorOrNull => _subject.errorOrNull; + + @override + bool get hasError => _subject.hasError; + + @override + StackTrace? get stackTrace => _subject.stackTrace; + + @override + StreamNotification? get lastEventOrNull => _subject.lastEventOrNull; +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast Stream that replays emitted items to any new listener, and +/// provides synchronous access to the list of emitted values. +class ReplayConnectableStream + extends AbstractConnectableStream, ReplayStream> + implements ReplayStream { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [ReplaySubject]. + ReplayConnectableStream(Stream stream, {int? maxSize, bool sync = false}) + : super( + stream, + ReplaySubject(maxSize: maxSize, sync: sync), + ); + + @override + List get values => _subject.values; + + @override + List get errors => _subject.errors; + + @override + List get stackTraces => _subject.stackTraces; +} + +/// A special [StreamSubscription] that not only cancels the connection to +/// the source [Stream], but also closes down a subject that drives the Stream. +class ConnectableStreamSubscription extends StreamSubscription { + final StreamSubscription _source; + final Subject _subject; + + /// Constructs a special [StreamSubscription], which will close the provided subject + /// when [cancel] is called. + ConnectableStreamSubscription(this._source, this._subject); + + @override + Future cancel() => + _source.cancel().then((_) => _subject.close()); + + @override + Never asFuture([E? futureValue]) => _unsupportedError(); + + @override + bool get isPaused => _source.isPaused; + + @override + Never onData(void Function(T data)? handleData) => _unsupportedError(); + + @override + Never onDone(void Function()? handleDone) => _unsupportedError(); + + @override + Never onError(Function? handleError) => _unsupportedError(); + + @override + void pause([Future? resumeSignal]) => _source.pause(resumeSignal); + + @override + void resume() => _source.resume(); + + Never _unsupportedError() => throw UnsupportedError( + 'Cannot change handlers of ConnectableStreamSubscription.'); +} + +/// Extends the Stream class with the ability to transform a single-subscription +/// Stream into a ConnectableStream. +extension ConnectableStreamExtensions on Stream { + /// Convert the current Stream into a [ConnectableStream] that can be listened + /// to multiple times. It will not begin emitting items from the original + /// Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publish(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // Subject + /// subscription.cancel(); + /// ``` + PublishConnectableStream publish() => + PublishConnectableStream(this, sync: true); + + /// Convert the current Stream into a [ValueConnectableStream] + /// that can be listened to multiple times. It will not begin emitting items + /// from the original Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays the latest emitted value to any new + /// listener. It also provides access to the latest value synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishValue(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the last emitted value + /// connectable.listen(print); // Prints 3 + /// await Future(() {}); + /// + /// // Can access the latest emitted value synchronously. Prints 3 + /// print(connectable.value); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject + /// subscription.cancel(); + /// ``` + ValueConnectableStream publishValue() => + ValueConnectableStream(this, sync: true); + + /// Convert the current Stream into a [ValueConnectableStream] + /// that can be listened to multiple times, providing an initial seeded value. + /// It will not begin emitting items from the original Stream + /// until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays the latest emitted value to any new + /// listener. It also provides access to the latest value synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishValueSeeded(0); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 0, 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the last emitted value + /// connectable.listen(print); // Prints 3 + /// await Future(() {}); + /// + /// // Can access the latest emitted value synchronously. Prints 3 + /// print(connectable.value); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject + /// subscription.cancel(); + /// ``` + ValueConnectableStream publishValueSeeded(T seedValue) => + ValueConnectableStream.seeded(this, seedValue, sync: true); + + /// Convert the current Stream into a [ReplayConnectableStream] + /// that can be listened to multiple times. It will not begin emitting items + /// from the original Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays a given number of items to any new + /// listener. It also provides access to the emitted values synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishReplay(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the emitted value, up to a specified + /// // maxSize + /// connectable.listen(print); // Prints 1, 2, 3 + /// await Future(() {}); + /// + /// // Can access a list of the emitted values synchronously. Prints [1, 2, 3] + /// print(connectable.values); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // ReplaySubject + /// subscription.cancel(); + /// ``` + ReplayConnectableStream publishReplay({int? maxSize}) => + ReplayConnectableStream(this, maxSize: maxSize, sync: true); + + /// Convert the current Stream into a new Stream that can be listened + /// to multiple times. It will automatically begin emitting items when first + /// listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream + /// final stream = Stream.fromIterable([1, 2, 3]).share(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // PublishSubject + /// subscription.cancel(); + /// ``` + Stream share() => publish().refCount(); + + /// Convert the current Stream into a new [ValueStream] that can + /// be listened to multiple times. It will automatically begin emitting items + /// when first listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for providing sync access to the latest + /// emitted value. + /// + /// It will replay the latest emitted value to any new listener. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareValue(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Synchronously print the latest value + /// print(stream.value); + /// + /// // Subscribe again later. This will print 3 because it receives the last + /// // emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ValueStream shareValue() => publishValue().refCount(); + + /// Convert the current Stream into a new [ValueStream] that can + /// be listened to multiple times, providing an initial value. + /// It will automatically begin emitting items when first listened to, + /// and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for providing sync access to the latest + /// emitted value. + /// + /// It will replay the latest emitted value to any new listener. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareValueSeeded(0); + /// + /// // Start listening to the source Stream. Will start printing 0, 1, 2, 3 + /// final subscription = stream.listen(print); + /// + /// // Synchronously print the latest value + /// print(stream.value); + /// + /// // Subscribe again later. This will print 3 because it receives the last + /// // emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ValueStream shareValueSeeded(T seedValue) => + publishValueSeeded(seedValue).refCount(); + + /// Convert the current Stream into a new [ReplayStream] that can + /// be listened to multiple times. It will automatically begin emitting items + /// when first listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for gaining access to the l + /// + /// It will replay the emitted values to any new listener, up to a given + /// [maxSize]. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareReplay(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Synchronously print the emitted values up to a given maxSize + /// // Prints [1, 2, 3] + /// print(stream.values); + /// + /// // Subscribe again later. This will print 1, 2, 3 because it receives the + /// // last emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // ReplaySubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ReplayStream shareReplay({int? maxSize}) => + publishReplay(maxSize: maxSize).refCount(); +} diff --git a/sandbox/reactivex/lib/src/streams/defer.dart b/sandbox/reactivex/lib/src/streams/defer.dart new file mode 100644 index 0000000..25a8a12 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/defer.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +/// The defer factory waits until a listener subscribes to it, and then it +/// creates a Stream with the given factory function. +/// +/// In some circumstances, waiting until the last minute (that is, until +/// subscription time) to generate the Stream can ensure that listeners +/// receive the freshest data. +/// +/// By default, DeferStreams are single-subscription. However, it's possible +/// to make them reusable. +/// +/// ### Example +/// +/// DeferStream(() => Stream.value(1)).listen(print); //prints 1 +class DeferStream extends Stream { + final Stream Function() _factory; + final bool _isReusable; + + @override + bool get isBroadcast => _isReusable; + + /// Constructs a [Stream] lazily, at the moment of subscription, using + /// the [streamFactory] + DeferStream(Stream Function() streamFactory, {bool reusable = false}) + : _isReusable = reusable, + _factory = reusable + ? streamFactory + : (() { + Stream? stream; + return () => stream ??= streamFactory(); + }()); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + Stream stream; + + try { + stream = _factory(); + } catch (e, s) { + return Stream.error(e, s).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} diff --git a/sandbox/reactivex/lib/src/streams/fork_join.dart b/sandbox/reactivex/lib/src/streams/fork_join.dart new file mode 100644 index 0000000..349909e --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/fork_join.dart @@ -0,0 +1,366 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// This operator is best used when you have a group of streams +/// and only care about the final emitted value of each. +/// One common use case for this is if you wish to issue multiple +/// requests on page load (or some other event) +/// and only want to take action when a response has been received for all. +/// +/// In this way it is similar to how you might use [Future.wait]. +/// +/// Be aware that if any of the inner streams supplied to forkJoin error +/// you will lose the value of any other streams that would or have already +/// completed if you do not catch the error correctly on the inner stream. +/// +/// If you are only concerned with all inner streams completing +/// successfully you can catch the error on the outside. +/// It's also worth noting that if you have an stream +/// that emits more than one item, and you are concerned with the previous +/// emissions forkJoin is not the correct choice. +/// +/// In these cases you may better off with an operator like combineLatest or zip. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the combiner function. +/// +/// ### Basic Example +/// +/// This constructor takes in an `Iterable>` and outputs a +/// `Stream>` whenever any of the values change from the source +/// stream. This is useful with a dynamic number of source streams! +/// +/// ForkJoinStream.list([ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']), +/// ]) +/// .listen(print); //prints ['a', 'b', 'D'] +/// +/// ### Example with combiner +/// +/// If you wish to combine the list of values into a new object before emitting, +/// you can provide the `combiner` function to the constructor. +/// +/// ForkJoinStream( +/// [ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']), +/// ], +/// (values) => values.last, +/// ) +/// .listen(print); //prints 'D' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to combine a specific number of Streams together with proper +/// types information for the value of each Stream, use the +/// [join2] - [join9] operators. +/// +/// ForkJoinStream.join2( +/// Stream.fromIterable([1]), +/// Stream.fromIterable([2, 3]), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 4 +class ForkJoinStream extends StreamView { + /// Constructs a [Stream] that awaits the last values of the [Stream]s + /// in [streams], then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + ForkJoinStream( + Iterable> streams, + R Function(List values) combiner, + ) : super(_buildStream(streams, combiner)); + + /// Constructs a [Stream] that awaits the last values of the [Stream]s + /// in [streams] and then emits these values as a [List]. + /// After this event, the [Stream] closes. + static ForkJoinStream> list( + Iterable> streams, + ) => + ForkJoinStream>( + streams, + (values) => values, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) combiner, + ) => + ForkJoinStream( + [streamOne, streamTwo], + (List values) => combiner(values[0] as A, values[1] as B), + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner, + ) => + ForkJoinStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner, + ) => + ForkJoinStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + static Stream _buildStream( + Iterable> streams, + R Function(List values) combiner, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + List? values; + + controller.onListen = () { + var completed = 0; + + StreamSubscription listen(int i, Stream stream) { + var hasValue = false; + + return stream.listen( + (value) { + hasValue = true; + values?[i] = value; + }, + onError: controller.addError, + onDone: () { + if (!hasValue) { + controller.addError(StateError('No element')); + controller.close(); + return; + } + + if (values == null) { + return; + } + if (++completed == subscriptions.length) { + final R combined; + try { + combined = combiner(List.unmodifiable(values!)); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + controller.add(combined); + controller.close(); + } + }, + ); + } + + subscriptions = streams.mapIndexed(listen).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + values = List.filled(subscriptions.length, null); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () { + values = null; + return subscriptions.cancelAll(); + }; + + return controller.stream; + } +} diff --git a/sandbox/reactivex/lib/src/streams/from_callable.dart b/sandbox/reactivex/lib/src/streams/from_callable.dart new file mode 100644 index 0000000..ee4a6ba --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/from_callable.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +/// Returns a Stream that, when listening to it, calls a function you specify +/// and then emits the value returned from that function. +/// +/// If result from invoking [callable] function: +/// - Is a [Future]: when the future completes, this stream will fire one event, either +/// data or error, and then close with a done-event. +/// - Is a [T]: this stream emits a single data event and then completes with a done event. +/// +/// By default, a [FromCallableStream] is a single-subscription Stream. However, it's possible +/// to make them reusable. +/// This Stream is effectively equivalent to one created by +/// `(() async* { yield await callable() }())` or `(() async* { yield callable(); }())`. +/// +/// [ReactiveX doc](http://reactivex.io/documentation/operators/from.html) +/// +/// ### Example +/// +/// FromCallableStream(() => 'Value').listen(print); // prints Value +/// +/// FromCallableStream(() async { +/// await Future.delayed(const Duration(seconds: 1)); +/// return 'Value'; +/// }).listen(print); // prints Value +class FromCallableStream extends Stream { + Stream? _stream; + + /// A function will be called at subscription time. + final FutureOr Function() callable; + final bool _isReusable; + + /// Construct a Stream that, when listening to it, calls a function you specify + /// and then emits the value returned from that function. + /// [reusable] indicates whether this Stream can be listened to multiple times or not. + FromCallableStream(this.callable, {bool reusable = false}) + : _isReusable = reusable; + + @override + bool get isBroadcast => _isReusable; + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + if (_isReusable || _stream == null) { + try { + final value = callable(); + + _stream = + value is Future ? Stream.fromFuture(value) : Stream.value(value); + } catch (e, s) { + _stream = Stream.error(e, s); + } + } + + return _stream!.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} diff --git a/sandbox/reactivex/lib/src/streams/merge.dart b/sandbox/reactivex/lib/src/streams/merge.dart new file mode 100644 index 0000000..2384052 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/merge.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Flattens the items emitted by the given streams into a single Stream +/// sequence. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#merge) +/// +/// ### Example +/// +/// MergeStream([ +/// TimerStream(1, Duration(days: 10)), +/// Stream.fromIterable([2]) +/// ]) +/// .listen(print); // prints 2, 1 +class MergeStream extends StreamView { + /// Constructs a [Stream] which flattens all events in [streams] and emits + /// them in a single sequence. + MergeStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + + controller.onListen = () { + var completed = 0; + + void onDone() { + if (++completed == subscriptions.length) { + controller.close(); + } + } + + subscriptions = streams + .map((s) => s.listen(controller.add, + onError: controller.addError, onDone: onDone)) + .toList(growable: false); + + if (subscriptions.isEmpty) { + controller.close(); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () => subscriptions.cancelAll(); + + return controller; + } +} + +/// Extends the Stream class with the ability to merge one stream with another. +extension MergeExtension on Stream { + /// Combines the items emitted by multiple streams into a single stream of + /// items. The items are emitted in the order they are emitted by their + /// sources. + /// + /// ### Example + /// + /// TimerStream(1, Duration(seconds: 10)) + /// .mergeWith([Stream.fromIterable([2])]) + /// .listen(print); // prints 2, 1 + Stream mergeWith(Iterable> streams) { + final stream = MergeStream([this, ...streams]); + + return isBroadcast + ? stream.asBroadcastStream(onCancel: (s) => s.cancel()) + : stream; + } +} diff --git a/sandbox/reactivex/lib/src/streams/never.dart b/sandbox/reactivex/lib/src/streams/never.dart new file mode 100644 index 0000000..fc6b18b --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/never.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +/// Returns a non-terminating stream sequence, which can be used to denote +/// an infinite duration. +/// +/// The never operator is one with very specific and limited behavior. These +/// are useful for testing purposes, and sometimes also for combining with +/// other Streams or as parameters to operators that expect other +/// Streams as parameters. +/// +/// ### Example +/// +/// NeverStream().listen(print); // Neither prints nor terminates +class NeverStream extends Stream { + // ignore: close_sinks + final _controller = StreamController(); + + /// Constructs a [Stream] which never emits an event and simply remains + /// open until implicitly closed by the developer. + NeverStream(); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} diff --git a/sandbox/reactivex/lib/src/streams/race.dart b/sandbox/reactivex/lib/src/streams/race.dart new file mode 100644 index 0000000..98b6333 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/race.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Given two or more source streams, emit all of the items from only +/// the first of these streams to emit an item or notification. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#race) +/// +/// ### Example +/// +/// RaceStream([ +/// TimerStream(1, Duration(days: 1)), +/// TimerStream(2, Duration(days: 2)), +/// TimerStream(3, Duration(seconds: 3)) +/// ]).listen(print); // prints 3 +class RaceStream extends StreamView { + /// Constructs a [Stream] which emits all events from a single [Stream] + /// inside [streams]. The selected [Stream] is the first one which emits + /// an event. + /// After this event, all other [Stream]s in [streams] are discarded. + RaceStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + + controller.onListen = () { + void reduceToWinner(int winnerIndex) { + final winner = subscriptions.removeAt(winnerIndex); + + subscriptions.cancelAll()?.onError((e, s) { + if (!controller.isClosed && controller.hasListener) { + controller.addError(e, s); + } + }); + + subscriptions = [winner]; + } + + void Function(T value) doUpdate(int index) { + return (T value) { + if (subscriptions.length > 1) { + reduceToWinner(index); + } + controller.add(value); + }; + } + + subscriptions = streams + .mapIndexed((index, stream) => stream.listen(doUpdate(index), + onError: controller.addError, onDone: controller.close)) + .toList(); + + if (subscriptions.isEmpty) { + controller.close(); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () => subscriptions.cancelAll(); + + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/range.dart b/sandbox/reactivex/lib/src/streams/range.dart new file mode 100644 index 0000000..83b4c1e --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/range.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +/// Returns a Stream that emits a sequence of Integers within a specified +/// range. +/// +/// ### Examples +/// +/// RangeStream(1, 3).listen((i) => print(i)); // Prints 1, 2, 3 +/// +/// RangeStream(3, 1).listen((i) => print(i)); // Prints 3, 2, 1 +class RangeStream extends Stream { + var _isListened = false; + final Stream _stream; + + /// Constructs a [Stream] which emits all integer values that exist + /// within the range between [startInclusive] and [endInclusive]. + RangeStream(int startInclusive, int endInclusive) + : _stream = _buildStream(startInclusive, endInclusive); + + @override + StreamSubscription listen(void Function(int event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + if (_isListened) { + throw StateError('Stream has already been listened to.'); + } + _isListened = true; + + return _stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + static Stream _buildStream(int startInclusive, int endInclusive) { + final length = (endInclusive - startInclusive).abs() + 1; + + int nextValue(int index) => startInclusive > endInclusive + ? startInclusive - index + : startInclusive + index; + + return Stream.fromIterable(Iterable.generate(length, nextValue)); + } +} diff --git a/sandbox/reactivex/lib/src/streams/repeat.dart b/sandbox/reactivex/lib/src/streams/repeat.dart new file mode 100644 index 0000000..9f8f9c5 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/repeat.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +/// Creates a [Stream] that will recreate and re-listen to the source +/// Stream the specified number of times until the [Stream] terminates +/// successfully. +/// +/// If [count] is not specified, it repeats indefinitely. +/// +/// ### Example +/// +/// RepeatStream((int repeatCount) => +/// Stream.value('repeat index: $repeatCount'), 3) +/// .listen((i) => print(i)); // Prints 'repeat index: 0, repeat index: 1, repeat index: 2' +class RepeatStream extends Stream { + /// The factory method used at subscription time + final Stream Function(int) streamFactory; + + /// The amount of repeat attempts that will be made + /// If 0, then an indefinite amount of attempts will be made. + final int? count; + int _repeatStep = 0; + StreamController? _controller; + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created with the provided factory method). + /// The count parameter specifies number of times the repeat will take place, + /// until this [Stream] terminates successfully. + /// If the count parameter is not specified, then this [Stream] will repeat + /// indefinitely. + RepeatStream(this.streamFactory, [this.count]); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + _controller ??= StreamController( + sync: true, + onListen: _maybeRepeatNext, + onPause: () => _subscription?.pause(), + onResume: () => _subscription?.resume(), + onCancel: () => _subscription?.cancel()); + + return _controller!.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _repeatNext() { + void onDone() { + _subscription?.cancel(); + + _maybeRepeatNext(); + } + + final controller = _controller!; + try { + _subscription = streamFactory(_repeatStep++).listen( + controller.add, + onError: controller.addError, + onDone: onDone, + cancelOnError: false, + ); + } catch (e, s) { + controller.addError(e, s); + } + } + + void _maybeRepeatNext() { + if (_repeatStep == count) { + _controller!.close(); + } else { + _repeatNext(); + } + } +} diff --git a/sandbox/reactivex/lib/src/streams/replay_stream.dart b/sandbox/reactivex/lib/src/streams/replay_stream.dart new file mode 100644 index 0000000..a9d7957 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/replay_stream.dart @@ -0,0 +1,26 @@ +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// An [Stream] that provides synchronous access to the emitted values +abstract class ReplayStream implements Stream { + /// Synchronously get the values stored in Subject. May be empty. + List get values; + + /// Synchronously get the errors and stack traces stored in Subject. May be empty. + List get errors; + + /// Synchronously get the stack traces of errors stored in Subject. May be empty. + List get stackTraces; +} + +/// Extension method on [ReplayStream] to access the emitted [ErrorAndStackTrace]s. +extension ErrorAndStackTracesReplayStreamExtension on ReplayStream { + /// Returns the emitted [ErrorAndStackTrace]s. + /// May be empty. + List get errorAndStackTraces => + errors.zipWith( + stackTraces, + (e, s) => ErrorAndStackTrace(e, s), + growable: false, + ); +} diff --git a/sandbox/reactivex/lib/src/streams/retry.dart b/sandbox/reactivex/lib/src/streams/retry.dart new file mode 100644 index 0000000..f90cc97 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/retry.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// Creates a [Stream] that will recreate and re-listen to the source +/// [Stream] the specified number of times until the [Stream] terminates +/// successfully. +/// +/// If the retry count is not specified, it retries indefinitely. If the retry +/// count is met, but the Stream has not terminated successfully, all of the errors +/// and StackTraces that caused the failure will be emitted. +/// +/// ### Example +/// +/// RetryStream(() => Stream.value(1)) +/// .listen((i) => print(i)); // Prints 1 +/// +/// RetryStream( +/// () => Stream.value(1).concatWith([Stream.error(Error())]), +/// 1, +/// ).listen( +/// print, +/// onError: (Object e, StackTrace s) => print(e), +/// ); // Prints 1, 1, Instance of 'Error', Instance of 'Error' +class RetryStream extends Stream { + /// The factory method used at subscription time + final Stream Function() streamFactory; + + /// The amount of retry attempts that will be made + /// If null, then an indefinite amount of attempts will be made. + final int? count; + + var _retryStep = 0; + final _errors = []; + late final StreamController _controller = StreamController( + sync: true, + onListen: _retry, + onPause: () => _subscription!.pause(), + onResume: () => _subscription!.resume(), + onCancel: () { + _errors.clear(); + return _subscription?.cancel(); + }, + ); + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created by the provided factory method) the specified number + /// of times until the [Stream] terminates successfully. + /// If [count] is not specified, it retries indefinitely. + RetryStream(this.streamFactory, [this.count]); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _retry() { + void onError(Object e, StackTrace s) { + _subscription!.cancel(); + _subscription = null; + + _errors.add(ErrorAndStackTrace(e, s)); + + if (count == _retryStep) { + for (var e in [..._errors]) { + _controller.addError(e.error, e.stackTrace); + } + _controller.close(); + } else { + ++_retryStep; + _retry(); + } + } + + _subscription = streamFactory().listen( + _controller.add, + onError: onError, + onDone: _controller.close, + cancelOnError: false, + ); + } +} diff --git a/sandbox/reactivex/lib/src/streams/retry_when.dart b/sandbox/reactivex/lib/src/streams/retry_when.dart new file mode 100644 index 0000000..05e6073 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/retry_when.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +/// Creates a Stream that will recreate and re-listen to the source +/// Stream when the notifier emits a new value. If the source Stream +/// emits an error or it completes, the Stream terminates. +/// +/// If the [retryWhenFactory] throws an error or returns a Stream that emits an error, +/// original error will be emitted. And then, the error from [retryWhenFactory] will be emitted +/// if it is not identical with original error. +/// +/// ### Basic Example +/// +/// ```dart +/// RetryWhenStream( +/// () => Stream.fromIterable([1]), +/// (Object error, StackTrace s) => throw error, +/// ).listen(print); // Prints 1 +/// ``` +/// +/// ### Periodic Example +/// +/// ```dart +/// RetryWhenStream( +/// () => Stream.periodic(const Duration(seconds: 1), (int i) => i) +/// .map((int i) => i == 2 ? throw 'exception' : i), +/// (Object e, StackTrace s) => +/// Rx.timer(null, const Duration(milliseconds: 200)), +/// ).take(4).listen(print); // Prints 0, 1, 0, 1 +/// ``` +/// +/// ### Complex Example +/// +/// ```dart +/// var errorHappened = false; +/// RetryWhenStream( +/// () => Stream.periodic(const Duration(seconds: 1), (i) => i).map((i) { +/// if (i == 3 && !errorHappened) { +/// throw 'We can take this. Please restart.'; +/// } else if (i == 4) { +/// throw 'It\'s enough.'; +/// } else { +/// return i; +/// } +/// }), +/// (e, s) { +/// errorHappened = true; +/// if (e == 'We can take this. Please restart.') { +/// return Stream.value('Ok. Here you go!'); +/// } else { +/// return Stream.error(e, s); +/// } +/// }, +/// ).listen(print, onError: print); // Prints 0, 1, 2, 0, 1, 2, 3, It's enough. +/// ``` +class RetryWhenStream extends Stream { + /// The factory method used at subscription time + final Stream Function() streamFactory; + + /// The factory method used to create the [Stream] which triggers a re-listen + final Stream Function( + Object error, + StackTrace stackTrace, + ) retryWhenFactory; + + late final _controller = StreamController( + sync: true, + onListen: _retry, + onPause: () => _subscription!.pause(), + onResume: () => _subscription!.resume(), + onCancel: () => _subscription?.cancel(), + ); + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created by the provided factory method). + /// The retry will trigger whenever the [Stream] created by the retryWhen + /// factory emits and event. + RetryWhenStream(this.streamFactory, this.retryWhenFactory); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _retry() { + void onError(Object originalError, StackTrace originalStacktrace) { + _cancelSubscription(); + + Stream retryStream; + try { + retryStream = retryWhenFactory(originalError, originalStacktrace); + } catch (e, s) { + return _addErrorAndClose(originalError, originalStacktrace, e, s); + } + + _subscription = retryStream.listen( + (_) { + _cancelSubscription(); + _retry(); + }, + onError: (Object e, StackTrace s) { + _cancelSubscription(); + _addErrorAndClose(originalError, originalStacktrace, e, s); + }, + cancelOnError: false, + ); + } + + _subscription = streamFactory().listen( + _controller.add, + onError: onError, + onDone: _controller.close, + cancelOnError: false, + ); + } + + void _addErrorAndClose( + Object originalError, + StackTrace originalStacktrace, + Object e, + StackTrace s, + ) { + if (identical(originalError, e)) { + _controller.addError(originalError, originalStacktrace); + } else { + _controller.addError(originalError, originalStacktrace); + _controller.addError(e, s); + } + _controller.close(); + } + + void _cancelSubscription() { + _subscription!.cancel(); + _subscription = null; + } +} diff --git a/sandbox/reactivex/lib/src/streams/sequence_equal.dart b/sandbox/reactivex/lib/src/streams/sequence_equal.dart new file mode 100644 index 0000000..5fd8d11 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/sequence_equal.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/zip.dart'; +import 'package:angel3_reactivex/src/transformers/materialize.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// Determine whether two Streams emit the same sequence of items. +/// You can provide an optional equals handler to determine equality. +/// +/// [Interactive marble diagram](https://rxmarbles.com/#sequenceEqual) +/// +/// ### Example +/// +/// SequenceEqualsStream([ +/// Stream.fromIterable([1, 2, 3, 4, 5]), +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// ]) +/// .listen(print); // prints true +class SequenceEqualStream extends Stream { + final StreamController _controller; + + /// Creates a [Stream] that emits true or false, depending on the + /// equality between the provided [Stream]s. + /// This single value is emitted when both provided [Stream]s are complete. + /// After this event, the [Stream] closes. + SequenceEqualStream( + Stream stream, + Stream other, { + bool Function(S s, T t)? dataEquals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + }) : _controller = _buildController(stream, other, dataEquals, errorEquals); + + @override + StreamSubscription listen(void Function(bool event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + + static StreamController _buildController( + Stream stream, + Stream other, + bool Function(S s, T t)? dataEquals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + ) { + dataEquals = dataEquals ?? (s, t) => s == t; + errorEquals = errorEquals ?? (e1, e2) => e1 == e2; + + late StreamController controller; + late StreamSubscription subscription; + + controller = StreamController( + sync: true, + onListen: () { + void emitAndClose([bool value = true]) => controller + ..add(value) + ..close(); + + bool compare(StreamNotification s, StreamNotification t) { + if (s.kind != t.kind) { + return false; + } + + switch (s.kind) { + case NotificationKind.data: + return dataEquals!( + s.requireDataValue, + t.requireDataValue, + ); + case NotificationKind.done: + return true; + case NotificationKind.error: + return errorEquals!( + s.requireErrorAndStackTrace, + t.requireErrorAndStackTrace, + ); + } + } + + subscription = + ZipStream.zip2(stream.materialize(), other.materialize(), compare) + .where((isEqual) => !isEqual) + .listen( + emitAndClose, + onError: controller.addError, + onDone: emitAndClose, + ); + }, + onPause: () => subscription.pause(), + onResume: () => subscription.resume(), + onCancel: () => subscription.cancel()); + + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/switch_latest.dart b/sandbox/reactivex/lib/src/streams/switch_latest.dart new file mode 100644 index 0000000..87650ad --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/switch_latest.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +/// Convert a [Stream] that emits [Stream]s (aka a 'Higher Order Stream') into a +/// single [Stream] that emits the items emitted by the most-recently-emitted of +/// those [Stream]s. +/// +/// This stream will unsubscribe from the previously-emitted Stream when a new +/// Stream is emitted from the source Stream. +/// +/// ### Example +/// +/// ```dart +/// final switchLatestStream = SwitchLatestStream( +/// Stream.fromIterable(>[ +/// Rx.timer('A', Duration(seconds: 2)), +/// Rx.timer('B', Duration(seconds: 1)), +/// Stream.value('C'), +/// ]), +/// ); +/// +/// // Since the first two Streams do not emit data for 1-2 seconds, and the 3rd +/// // Stream will be emitted before that time, only data from the 3rd Stream +/// // will be emitted to the listener. +/// switchLatestStream.listen(print); // prints 'C' +/// ``` +class SwitchLatestStream extends Stream { + // ignore: close_sinks + final StreamController _controller; + + /// Constructs a [Stream] that emits [Stream]s (aka a 'Higher Order Stream") into a + /// single [Stream] that emits the items emitted by the most-recently-emitted of + /// those [Stream]s. + SwitchLatestStream(Stream> streams) + : _controller = _buildController(streams); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + static StreamController _buildController(Stream> streams) { + late StreamController controller; + late StreamSubscription> subscription; + StreamSubscription? otherSubscription; + var leftClosed = false, rightClosed = false, hasMainEvent = false; + + controller = StreamController( + sync: true, + onListen: () { + void closeLeft() { + leftClosed = true; + + if (rightClosed || !hasMainEvent) controller.close(); + } + + void closeRight() { + rightClosed = true; + + if (leftClosed) controller.close(); + } + + subscription = streams.listen((stream) { + try { + otherSubscription?.cancel(); + + hasMainEvent = true; + + otherSubscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: closeRight, + ); + } catch (e, s) { + controller.addError(e, s); + } + }, onError: controller.addError, onDone: closeLeft); + }, + onPause: () { + subscription.pause(); + otherSubscription?.pause(); + }, + onResume: () { + subscription.resume(); + otherSubscription?.resume(); + }, + onCancel: () async { + await subscription.cancel(); + + if (hasMainEvent) await otherSubscription?.cancel(); + }); + + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/timer.dart b/sandbox/reactivex/lib/src/streams/timer.dart new file mode 100644 index 0000000..c456b66 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/timer.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +/// Emits the given value after a specified amount of time. +/// +/// ### Example +/// +/// TimerStream('hi', Duration(minutes: 1)) +/// .listen((i) => print(i)); // print 'hi' after 1 minute +class TimerStream extends Stream { + final StreamController _controller; + + /// Constructs a [Stream] which emits [value] after the specified [Duration]. + TimerStream(T value, Duration duration) + : _controller = _buildController(value, duration); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + static StreamController _buildController(T value, Duration duration) { + final watch = Stopwatch(); + Timer? timer; + late StreamController controller; + Duration? totalElapsed = Duration.zero; + + void onResume() { + // Already cancelled or is not paused. + if (totalElapsed == null || timer != null) return; + + totalElapsed = totalElapsed! + watch.elapsed; + watch.start(); + + timer = Timer(duration - totalElapsed!, () { + controller.add(value); + controller.close(); + }); + } + + controller = StreamController( + sync: true, + onListen: () { + watch.start(); + timer = Timer(duration, () { + controller.add(value); + controller.close(); + }); + }, + onPause: () { + timer?.cancel(); + timer = null; + watch.stop(); + }, + onResume: onResume, + onCancel: () { + timer?.cancel(); + timer = null; + totalElapsed = null; + }, + ); + return controller; + } +} diff --git a/sandbox/reactivex/lib/src/streams/using.dart b/sandbox/reactivex/lib/src/streams/using.dart new file mode 100644 index 0000000..1cbed85 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/using.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +/// When listener listens to it, creates a resource object from resource factory function, +/// and creates a [Stream] from the given factory function and resource as argument. +/// Finally when the stream finishes emitting items or stream subscription +/// is cancelled (call [StreamSubscription.cancel] or `Stream.listen(cancelOnError: true)`), +/// call the disposer function on resource object. +/// The disposer is called after the future returned from [StreamSubscription.cancel] completes. +/// +/// The [UsingStream] is a way you can instruct a Stream to create +/// a resource that exists only during the lifespan of the Stream +/// and is disposed of when the Stream terminates. +/// +/// [Marble diagram](http://reactivex.io/documentation/operators/images/using.c.png) +/// +/// ### Example +/// +/// UsingStream>( +/// resourceFactory: () => Queue.of([1, 2, 3]), +/// streamFactory: (r) => Stream.fromIterable(r), +/// disposer: (r) => r.clear(), +/// ).listen(print); // prints 1, 2, 3 +class UsingStream extends StreamView { + /// Construct a [UsingStream] that creates a resource object from [resourceFactory], + /// and then creates a [Stream] from [streamFactory] and resource as argument. + /// When the Stream terminates, call [disposer] on resource object. + UsingStream({ + required FutureOr Function() resourceFactory, + required Stream Function(R) streamFactory, + required FutureOr Function(R) disposer, + }) : super(_buildStream(resourceFactory, streamFactory, disposer)); + + static Stream _buildStream( + FutureOr Function() resourceFactory, + Stream Function(R) streamFactory, + FutureOr Function(R) disposer, + ) { + late StreamController controller; + var resourceCreated = false; + late R resource; + StreamSubscription? subscription; + + void useResource(R r) { + resource = r; + resourceCreated = true; + + Stream stream; + try { + stream = streamFactory(r); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + subscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } + + controller = StreamController( + sync: true, + onListen: () { + final FutureOr resourceOrFuture; + try { + resourceOrFuture = resourceFactory(); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + if (resourceOrFuture is R) { + useResource(resourceOrFuture); + } else { + resourceOrFuture.then((r) { + // if the controller was cancelled before the resource is created, + // we should dispose the resource + if (!controller.hasListener) { + disposer(r); + } else { + useResource(r); + } + }).onError((e, s) { + controller.addError(e, s); + controller.close(); + }); + } + }, + onPause: () => subscription?.pause(), + onResume: () => subscription?.resume(), + onCancel: () { + final cancelFuture = subscription?.cancel(); + subscription = null; + + return cancelFuture == null + ? (resourceCreated ? disposer(resource) : null) + : cancelFuture + .then((_) => resourceCreated ? disposer(resource) : null); + }, + ); + + return controller.stream; + } +} diff --git a/sandbox/reactivex/lib/src/streams/value_stream.dart b/sandbox/reactivex/lib/src/streams/value_stream.dart new file mode 100644 index 0000000..2df9a86 --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/value_stream.dart @@ -0,0 +1,97 @@ +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// A [Stream] that provides synchronous access to the last emitted value (aka. data event). +abstract class ValueStream implements Stream { + /// Returns the last emitted value, failing if there is no value. + /// See [hasValue] to determine whether [value] has already been set. + /// + /// Throws [ValueStreamError] if this Stream has no value. + /// + /// See also [valueOrNull]. + T get value; + + /// Returns the last emitted value, or `null` if value events haven't yet been emitted. + T? get valueOrNull; + + /// Returns `true` when [value] is available, + /// meaning this Stream has emitted at least one value. + bool get hasValue; + + /// Returns last emitted error, failing if there is no error. + /// See [hasError] to determine whether [error] has already been set. + /// + /// Throws [ValueStreamError] if this Stream has no error. + /// + /// See also [errorOrNull]. + Object get error; + + /// Returns the last emitted error, or `null` if error events haven't yet been emitted. + Object? get errorOrNull; + + /// Returns `true` when [error] is available, + /// meaning this Stream has emitted at least one error. + bool get hasError; + + /// Returns [StackTrace] of the last emitted error. + /// + /// If error events haven't yet been emitted, + /// or the last emitted error didn't have a stack trace, + /// the returned value is `null`. + StackTrace? get stackTrace; + + /// Returns the last emitted event (either data/value or error event). + /// `null` if no value or error events have been emitted yet. + StreamNotification? get lastEventOrNull; +} + +/// Extension methods on [ValueStream] related to [lastEventOrNull]. +extension LastEventValueStreamExtensions on ValueStream { + /// Returns `true` if the last emitted event is a data event (aka. a value event). + bool get isLastEventValue => lastEventOrNull?.isData ?? false; + + /// Returns `true` if the last emitted event is an error event. + bool get isLastEventError => lastEventOrNull?.isError ?? false; +} + +/// Extension method on [ValueStream] to access the last emitted [ErrorAndStackTrace]. +extension ErrorAndStackTraceValueStreamExtension on ValueStream { + /// Returns the last emitted [ErrorAndStackTrace], + /// or `null` if no error events have been emitted yet. + ErrorAndStackTrace? get errorAndStackTraceOrNull { + final error = errorOrNull; + return error == null ? null : ErrorAndStackTrace(error, stackTrace); + } +} + +enum _MissingCase { + value, + error, +} + +/// The error throw by [ValueStream.value] or [ValueStream.error]. +class ValueStreamError extends Error { + final _MissingCase _missingCase; + + ValueStreamError._(this._missingCase); + + /// Construct an [ValueStreamError] thrown by [ValueStream.value] when there is no value. + factory ValueStreamError.hasNoValue() => + ValueStreamError._(_MissingCase.value); + + /// Construct an [ValueStreamError] thrown by [ValueStream.error] when there is no error. + factory ValueStreamError.hasNoError() => + ValueStreamError._(_MissingCase.error); + + @override + String toString() { + switch (_missingCase) { + case _MissingCase.value: + return 'ValueStream has no value. You should check ValueStream.hasValue ' + 'before accessing ValueStream.value, or use ValueStream.valueOrNull instead.'; + case _MissingCase.error: + return 'ValueStream has no error. You should check ValueStream.hasError ' + 'before accessing ValueStream.error, or use ValueStream.errorOrNull instead.'; + } + } +} diff --git a/sandbox/reactivex/lib/src/streams/zip.dart b/sandbox/reactivex/lib/src/streams/zip.dart new file mode 100644 index 0000000..5caf6eb --- /dev/null +++ b/sandbox/reactivex/lib/src/streams/zip.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Merges the specified streams into one stream sequence using the given +/// zipper Function whenever all of the stream sequences have produced +/// an element at a corresponding index. +/// +/// It applies this function in strict sequence, so the first item emitted by +/// the new Stream will be the result of the function applied to the first +/// item emitted by Stream #1 and the first item emitted by Stream #2; +/// the second item emitted by the new ZipStream will be the result of +/// the function applied to the second item emitted by Stream #1 and the +/// second item emitted by Stream #2; and so forth. It will only emit as +/// many items as the number of items emitted by the source Stream that +/// emits the fewest items. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the zipper function. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#zip) +/// +/// ### Basic Example +/// +/// ZipStream( +/// [ +/// Stream.fromIterable(['A']), +/// Stream.fromIterable(['B']), +/// Stream.fromIterable(['C', 'D']), +/// ], +/// (values) => values.join(), +/// ).listen(print); // prints 'ABC' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to zip a specific number of Streams together with proper types +/// information for the value of each Stream, use the [zip2] - [zip9] operators. +/// +/// ZipStream.zip2( +/// Stream.fromIterable(['A']), +/// Stream.fromIterable(['B', 'C']), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 'AB' +class ZipStream extends StreamView { + /// Constructs a [Stream] which merges the specified [streams] into a sequence using the given + /// [zipper] Function, whenever all of the [streams] have produced + /// an element at a corresponding index. + ZipStream( + Iterable> streams, + R Function(List values) zipper, + ) : super(_buildController(streams, zipper).stream); + + /// Constructs a [Stream] which merges the specified [streams] into a [List], + /// containing values that were produced by the [streams] at a corresponding index. + static ZipStream> list(Iterable> streams) { + return ZipStream>( + streams, + (List values) => values, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) zipper, + ) { + return ZipStream( + [streamOne, streamTwo], + (List values) => zipper(values[0] as A, values[1] as B), + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG, streamH], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) zipper, + ) { + return ZipStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + } + + static StreamController _buildController( + Iterable> streams, + R Function(List values) zipper, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + var pendingSubscriptions = >[]; + + controller.onListen = () { + Completer? completeCurrent; + late final _Window window; + + // resets variables for the next zip window + void next() { + completeCurrent?.complete(null); + completeCurrent = Completer(); + + pendingSubscriptions = subscriptions.toList(); + } + + void Function(T value) doUpdate(int index) { + return (T value) { + window.onValue(index, value); + + if (window.isComplete) { + // all streams emitted for the current zip index + // dispatch event and reset for next + final R combined; + try { + combined = zipper(window.flush()); + } catch (e, s) { + controller.addError(e, s); + return; + } + controller.add(combined); + + // reset for next zip event + next(); + } else { + // other streams are still pending to get to the next + // zip event index. + // pause this subscription while we await the others + final subscription = subscriptions[index] + ..pause(completeCurrent!.future); + + pendingSubscriptions.remove(subscription); + } + }; + } + + subscriptions = streams + .mapIndexed((index, stream) => stream.listen(doUpdate(index), + onError: controller.addError, onDone: controller.close)) + .toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + window = _Window(subscriptions.length); + next(); + } + }; + controller.onPause = () => pendingSubscriptions.pauseAll(); + controller.onResume = () => pendingSubscriptions.resumeAll(); + controller.onCancel = () => pendingSubscriptions.cancelAll(); + + return controller; + } +} + +/// A window keeps track of the values emitted by the different +/// zipped Streams. +class _Window { + final int size; + final List _values; + + int _valuesReceived = 0; + + bool get isComplete => _valuesReceived == size; + + _Window(this.size) : _values = List.filled(size, null); + + void onValue(int index, T value) { + _values[index] = value; + + _valuesReceived++; + } + + List flush() { + _valuesReceived = 0; + + return List.unmodifiable(_values); + } +} + +/// Extends the Stream class with the ability to zip one Stream with another. +extension ZipWithExtension on Stream { + /// Returns a Stream that combines the current stream together with another + /// stream using a given zipper function. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .zipWith(Stream.fromIterable([2]), (one, two) => one + two) + /// .listen(print); // prints 3 + Stream zipWith(Stream other, R Function(T t, S s) zipper) { + final stream = ZipStream.zip2(this, other, zipper); + + return isBroadcast + ? stream.asBroadcastStream(onCancel: (s) => s.cancel()) + : stream; + } +} diff --git a/sandbox/reactivex/lib/src/subjects/behavior_subject.dart b/sandbox/reactivex/lib/src/subjects/behavior_subject.dart new file mode 100644 index 0000000..4a9f7d4 --- /dev/null +++ b/sandbox/reactivex/lib/src/subjects/behavior_subject.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/streams/value_stream.dart'; +import 'package:angel3_reactivex/src/subjects/subject.dart'; +import 'package:angel3_reactivex/src/transformers/start_with.dart'; +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:angel3_reactivex/src/utils/empty.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// A special StreamController that captures the latest item that has been +/// added to the controller, and emits that as the first item to any new +/// listener. +/// +/// This subject allows sending data, error and done events to the listener. +/// The latest item that has been added to the subject will be sent to any +/// new listeners of the subject. After that, any new events will be +/// appropriately sent to the listeners. It is possible to provide a seed value +/// that will be emitted if no items have been added to the subject. +/// +/// BehaviorSubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = BehaviorSubject(); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 3 +/// subject.stream.listen(print); // prints 3 +/// subject.stream.listen(print); // prints 3 +/// +/// ### Example with seed value +/// +/// final subject = BehaviorSubject.seeded(1); +/// +/// subject.stream.listen(print); // prints 1 +/// subject.stream.listen(print); // prints 1 +/// subject.stream.listen(print); // prints 1 +class BehaviorSubject extends Subject implements ValueStream { + final _Wrapper _wrapper; + + BehaviorSubject._( + StreamController controller, + Stream stream, + this._wrapper, + ) : super(controller, stream); + + /// Constructs a [BehaviorSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory BehaviorSubject({ + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final wrapper = _Wrapper(); + + return BehaviorSubject._( + controller, + Rx.defer(_deferStream(wrapper, controller, sync), reusable: true), + wrapper); + } + + /// Constructs a [BehaviorSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// [seedValue] becomes the current [value] and is emitted immediately. + /// + /// See also [StreamController.broadcast] + factory BehaviorSubject.seeded( + T seedValue, { + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final wrapper = _Wrapper.seeded(seedValue); + + return BehaviorSubject._( + controller, + Rx.defer(_deferStream(wrapper, controller, sync), reusable: true), + wrapper, + ); + } + + static Stream Function() _deferStream( + _Wrapper wrapper, StreamController controller, bool sync) => + () { + final errorAndStackTrace = wrapper.errorAndStackTrace; + if (errorAndStackTrace != null && !wrapper.isValue) { + return controller.stream.transform( + StartWithErrorStreamTransformer( + errorAndStackTrace.error, + errorAndStackTrace.stackTrace, + ), + ); + } + + final value = wrapper.value; + if (isNotEmpty(value) && wrapper.isValue) { + return controller.stream + .transform(StartWithStreamTransformer(value as T)); + } + + return controller.stream; + }; + + @override + void onAdd(T event) => _wrapper.setValue(event); + + @override + void onAddError(Object error, [StackTrace? stackTrace]) => + _wrapper.setError(error, stackTrace); + + @override + ValueStream get stream => _BehaviorSubjectStream(this); + + @override + bool get hasValue => isNotEmpty(_wrapper.value); + + @override + T get value { + final value = _wrapper.value; + if (isNotEmpty(value)) { + return value as T; + } + throw ValueStreamError.hasNoValue(); + } + + @override + T? get valueOrNull => unbox(_wrapper.value); + + /// Set and emit the new value. + set value(T newValue) => add(newValue); + + @override + bool get hasError => _wrapper.errorAndStackTrace != null; + + @override + Object? get errorOrNull => _wrapper.errorAndStackTrace?.error; + + @override + Object get error { + final errorAndSt = _wrapper.errorAndStackTrace; + if (errorAndSt != null) { + return errorAndSt.error; + } + throw ValueStreamError.hasNoError(); + } + + @override + StackTrace? get stackTrace => _wrapper.errorAndStackTrace?.stackTrace; + + @override + StreamNotification? get lastEventOrNull { + // data event + if (_wrapper.isValue) { + return StreamNotification.data(_wrapper.value as T); + } + + // error event + final errorAndSt = _wrapper.errorAndStackTrace; + if (errorAndSt != null) { + return ErrorNotification(errorAndSt); + } + + // no event + return null; + } +} + +class _Wrapper { + var isValue = false; + var value = EMPTY; + ErrorAndStackTrace? errorAndStackTrace; + + /// Non-seeded constructor + _Wrapper() : isValue = false; + + _Wrapper.seeded(T v) { + setValue(v); + } + + void setValue(T event) { + value = event; + isValue = true; + } + + void setError(Object error, StackTrace? stackTrace) { + errorAndStackTrace = ErrorAndStackTrace(error, stackTrace); + isValue = false; + } +} + +class _BehaviorSubjectStream extends Stream implements ValueStream { + final BehaviorSubject _subject; + + _BehaviorSubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _BehaviorSubjectStream && + identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + Object get error => _subject.error; + + @override + Object? get errorOrNull => _subject.errorOrNull; + + @override + bool get hasError => _subject.hasError; + + @override + bool get hasValue => _subject.hasValue; + + @override + StackTrace? get stackTrace => _subject.stackTrace; + + @override + T get value => _subject.value; + + @override + T? get valueOrNull => _subject.valueOrNull; + + @override + StreamNotification? get lastEventOrNull => _subject.lastEventOrNull; +} diff --git a/sandbox/reactivex/lib/src/subjects/publish_subject.dart b/sandbox/reactivex/lib/src/subjects/publish_subject.dart new file mode 100644 index 0000000..35bbc6d --- /dev/null +++ b/sandbox/reactivex/lib/src/subjects/publish_subject.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/subjects/subject.dart'; + +/// Exactly like a normal broadcast StreamController with one exception: +/// this class is both a Stream and Sink. +/// +/// This Subject allows sending data, error and done events to the listener. +/// +/// PublishSubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = PublishSubject(); +/// +/// // observer1 will receive all data and done events +/// subject.stream.listen(observer1); +/// subject.add(1); +/// subject.add(2); +/// +/// // observer2 will only receive 3 and done event +/// subject.stream.listen(observer2); +/// subject.add(3); +/// subject.close(); +class PublishSubject extends Subject { + PublishSubject._(StreamController controller, Stream stream) + : super(controller, stream); + + /// Constructs a [PublishSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory PublishSubject( + {void Function()? onListen, + void Function()? onCancel, + bool sync = false}) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + return PublishSubject._( + controller, + controller.stream, + ); + } +} diff --git a/sandbox/reactivex/lib/src/subjects/replay_subject.dart b/sandbox/reactivex/lib/src/subjects/replay_subject.dart new file mode 100644 index 0000000..5181a07 --- /dev/null +++ b/sandbox/reactivex/lib/src/subjects/replay_subject.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/streams/replay_stream.dart'; +import 'package:angel3_reactivex/src/subjects/subject.dart'; +import 'package:angel3_reactivex/src/transformers/start_with.dart'; +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/empty.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// A special StreamController that captures all of the items that have been +/// added to the controller, and emits those as the first items to any new +/// listener. +/// +/// This subject allows sending data, error and done events to the listener. +/// As items are added to the subject, the ReplaySubject will store them. +/// When the stream is listened to, those recorded items will be emitted to +/// the listener. After that, any new events will be appropriately sent to the +/// listeners. It is possible to cap the number of stored events by setting +/// a maxSize value. +/// +/// ReplaySubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = ReplaySubject(); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 1, 2, 3 +/// subject.stream.listen(print); // prints 1, 2, 3 +/// subject.stream.listen(print); // prints 1, 2, 3 +/// +/// ### Example with maxSize +/// +/// final subject = ReplaySubject(maxSize: 2); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 2, 3 +/// subject.stream.listen(print); // prints 2, 3 +/// subject.stream.listen(print); // prints 2, 3 +class ReplaySubject extends Subject implements ReplayStream { + final Queue<_Event> _queue; + final int? _maxSize; + + /// Constructs a [ReplaySubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory ReplaySubject({ + int? maxSize, + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final queue = Queue<_Event>(); + + return ReplaySubject._( + controller, + Rx.defer( + () => queue.toList(growable: false).reversed.fold( + controller.stream, + (stream, event) { + final errorAndStackTrace = event.errorAndStackTrace; + + if (errorAndStackTrace != null) { + return stream.transform( + StartWithErrorStreamTransformer( + errorAndStackTrace.error, + errorAndStackTrace.stackTrace, + ), + ); + } else { + return stream + .transform(StartWithStreamTransformer(event.data as T)); + } + }, + ), + reusable: true, + ), + queue, + maxSize, + ); + } + + ReplaySubject._( + StreamController controller, + Stream stream, + this._queue, + this._maxSize, + ) : super(controller, stream); + + @override + void onAdd(T event) { + if (_queue.length == _maxSize) { + _queue.removeFirst(); + } + + _queue.add(_Event.data(event)); + } + + @override + void onAddError(Object error, [StackTrace? stackTrace]) { + if (_queue.length == _maxSize) { + _queue.removeFirst(); + } + + _queue.add(_Event.error(ErrorAndStackTrace(error, stackTrace))); + } + + @override + List get values => _queue + .where((event) => event.errorAndStackTrace == null) + .map((event) => event.data as T) + .toList(growable: false); + + @override + List get errors => _queue + .mapNotNull((event) => event.errorAndStackTrace?.error) + .toList(growable: false); + + @override + List get stackTraces => _queue + .mapNotNull((event) => event.errorAndStackTrace) + .map((errorAndStackTrace) => errorAndStackTrace.stackTrace) + .toList(growable: false); + + @override + ReplayStream get stream => _ReplaySubjectStream(this); +} + +class _Event { + final Object? data; + final ErrorAndStackTrace? errorAndStackTrace; + + _Event._({required this.data, required this.errorAndStackTrace}); + + factory _Event.data(T data) => _Event._(data: data, errorAndStackTrace: null); + + factory _Event.error(ErrorAndStackTrace e) => + _Event._(errorAndStackTrace: e, data: EMPTY); +} + +class _ReplaySubjectStream extends Stream implements ReplayStream { + final ReplaySubject _subject; + + _ReplaySubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + @override + List get values => _subject.values; + + @override + List get errors => _subject.errors; + + @override + List get stackTraces => _subject.stackTraces; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _ReplaySubjectStream && identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} diff --git a/sandbox/reactivex/lib/src/subjects/subject.dart b/sandbox/reactivex/lib/src/subjects/subject.dart new file mode 100644 index 0000000..746188d --- /dev/null +++ b/sandbox/reactivex/lib/src/subjects/subject.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +/// The base for all Subjects. If you'd like to create a new Subject, +/// extend from this class. +/// +/// It handles all of the nitty-gritty details that conform to the +/// StreamController spec and don't need to be repeated over and +/// over. +/// +/// Please see `PublishSubject` for the simplest example of how to +/// extend from this class, or `BehaviorSubject` for a slightly more +/// complex example. +abstract class Subject extends StreamView implements StreamController { + final StreamController _controller; + + bool _isAddingStreamItems = false; + + /// Constructs a [Subject] which wraps the provided [controller]. + /// This constructor is applicable only for classes that extend [Subject]. + /// + /// To guarantee the contract of a [Subject], the [controller] must be + /// a broadcast [StreamController] and the [stream] must also be a broadcast [Stream]. + Subject(StreamController controller, Stream stream) + : _controller = controller, + assert(stream.isBroadcast, 'Subject requires a broadcast stream'), + super(stream); + + @override + StreamSink get sink => _StreamSinkWrapper(this); + + @override + ControllerCallback? get onListen => _controller.onListen; + + @override + set onListen(void Function()? onListenHandler) { + _controller.onListen = onListenHandler; + } + + @override + Stream get stream => _SubjectStream(this); + + @override + ControllerCallback get onPause => + throw UnsupportedError('Subjects do not support pause callbacks'); + + @override + set onPause(void Function()? onPauseHandler) => + throw UnsupportedError('Subjects do not support pause callbacks'); + + @override + ControllerCallback get onResume => + throw UnsupportedError('Subjects do not support resume callbacks'); + + @override + set onResume(void Function()? onResumeHandler) => + throw UnsupportedError('Subjects do not support resume callbacks'); + + @override + ControllerCancelCallback? get onCancel => _controller.onCancel; + + @override + set onCancel(ControllerCancelCallback? onCancelHandler) { + _controller.onCancel = onCancelHandler; + } + + @override + bool get isClosed => _controller.isClosed; + + @override + bool get isPaused => _controller.isPaused; + + @override + bool get hasListener => _controller.hasListener; + + @override + Future get done => _controller.done; + + @override + void addError(Object error, [StackTrace? stackTrace]) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add an error while items are being added from addStream'); + } + + _addError(error, stackTrace); + } + + void _addError(Object error, [StackTrace? stackTrace]) { + if (!_controller.isClosed) { + onAddError(error, stackTrace); + } + + // if the controller is closed, calling addError() will throw an StateError. + // that is expected behavior. + _controller.addError(error, stackTrace); + } + + /// An extension point for sub-classes. Perform any side-effect / state + /// management you need to here, rather than overriding the `add` method + /// directly. + void onAddError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream source, {bool? cancelOnError}) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add items while items are being added from addStream'); + } + _isAddingStreamItems = true; + + final completer = Completer(); + void complete() { + if (!completer.isCompleted) { + _isAddingStreamItems = false; + completer.complete(); + } + } + + source.listen( + _add, + onError: identical(cancelOnError, true) + ? (Object e, StackTrace s) { + _addError(e, s); + complete(); + } + : _addError, + onDone: complete, + cancelOnError: cancelOnError, + ); + + return completer.future; + } + + @override + void add(T event) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add items while items are being added from addStream'); + } + + _add(event); + } + + void _add(T event) { + if (!_controller.isClosed) { + onAdd(event); + } + + // if the controller is closed, calling add() will throw an StateError. + // that is expected behavior. + _controller.add(event); + } + + /// An extension point for sub-classes. Perform any side-effect / state + /// management you need to here, rather than overriding the `add` method + /// directly. + void onAdd(T event) {} + + @override + Future close() { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot close the subject while items are being added from addStream'); + } + + return _controller.close(); + } +} + +class _SubjectStream extends Stream { + final Subject _subject; + + _SubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _SubjectStream && identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} + +/// A class that exposes only the [StreamSink] interface of an object. +class _StreamSinkWrapper implements StreamSink { + final StreamController _target; + + _StreamSinkWrapper(this._target); + + @override + void add(T data) { + _target.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _target.addError(error, stackTrace); + } + + @override + Future close() => _target.close(); + + @override + Future addStream(Stream source) => _target.addStream(source); + + @override + Future get done => _target.done; +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/backpressure.dart b/sandbox/reactivex/lib/src/transformers/backpressure/backpressure.dart new file mode 100644 index 0000000..c4a544c --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/backpressure.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +/// The strategy that is used to determine how and when a new window is created. +enum WindowStrategy { + /// cancels the open window (if any) and immediately opens a fresh one. + everyEvent, + + /// waits until the current open window completes, then when the + /// source [Stream] emits a next event, it opens a new window. + eventAfterLastWindow, + + /// opens a recurring window right after the very first event on + /// the source [Stream] is emitted. + firstEventOnly, + + /// does not open any windows, rather all events are buffered and emitted + /// whenever the handler triggers, after this trigger, the buffer is cleared. + onHandler +} + +class _BackpressureStreamSink extends ForwardingSink { + final WindowStrategy _strategy; + final Stream Function(S event)? _windowStreamFactory; + final T Function(S event)? _onWindowStart; + final T Function(List queue)? _onWindowEnd; + final int _startBufferEvery; + final bool Function(List queue)? _closeWindowWhen; + final bool _ignoreEmptyWindows; + final bool _dispatchOnClose; + final Queue queue = DoubleLinkedQueue(); + final int? maxLengthQueue; + var skip = 0; + var _hasData = false; + var _mainClosed = false; + StreamSubscription? _windowSubscription; + + _BackpressureStreamSink( + this._strategy, + this._windowStreamFactory, + this._onWindowStart, + this._onWindowEnd, + this._startBufferEvery, + this._closeWindowWhen, + this._ignoreEmptyWindows, + this._dispatchOnClose, + this.maxLengthQueue, + ); + + @override + void onData(S data) { + _hasData = true; + maybeCreateWindow(data, sink); + + if (skip == 0) { + queue.add(data); + + if (maxLengthQueue != null && queue.length > maxLengthQueue!) { + queue.removeFirstElements(queue.length - maxLengthQueue!); + } + } + + if (skip > 0) { + skip--; + } + + maybeCloseWindow(sink); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _mainClosed = true; + + if (_strategy == WindowStrategy.eventAfterLastWindow) { + return; + } + + // treat the final event as a Window that opens + // and immediately closes again + if (_dispatchOnClose && queue.isNotEmpty) { + resolveWindowStart(queue.last, sink); + } + + resolveWindowEnd(sink, true); + + queue.clear(); + + _windowSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _windowSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _windowSubscription?.pause(); + + @override + void onResume() => _windowSubscription?.resume(); + + void maybeCreateWindow(S event, EventSink sink) { + switch (_strategy) { + // for example throttle + case WindowStrategy.eventAfterLastWindow: + if (_windowSubscription != null) return; + + _windowSubscription = singleWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + // for example scan + case WindowStrategy.firstEventOnly: + if (_windowSubscription != null) return; + + _windowSubscription = multiWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + // for example debounce + case WindowStrategy.everyEvent: + _windowSubscription?.cancel(); + + _windowSubscription = singleWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + case WindowStrategy.onHandler: + break; + } + } + + void maybeCloseWindow(EventSink sink) { + if (_closeWindowWhen != null && _closeWindowWhen!(unmodifiableQueue)) { + resolveWindowEnd(sink); + } + } + + StreamSubscription singleWindow(S event, EventSink sink) => + buildStream(event, sink).take(1).listen( + null, + onError: sink.addError, + onDone: () => resolveWindowEnd(sink, _mainClosed), + ); + + // opens a new Window which is kept open until the main Stream + // closes. + StreamSubscription multiWindow(S event, EventSink sink) => + buildStream(event, sink).listen( + (dynamic _) => resolveWindowEnd(sink), + onError: sink.addError, + onDone: () => resolveWindowEnd(sink), + ); + + Stream buildStream(S event, EventSink sink) { + Stream stream; + + _windowSubscription?.cancel(); + + stream = _windowStreamFactory!(event); + + return stream; + } + + void resolveWindowStart(S event, EventSink sink) { + if (_onWindowStart != null) { + sink.add(_onWindowStart!(event)); + } + } + + void resolveWindowEnd(EventSink sink, [bool isControllerClosing = false]) { + if (isControllerClosing && + _strategy == WindowStrategy.eventAfterLastWindow) { + if (_dispatchOnClose && + _hasData && + queue.length > 1 && + _onWindowEnd != null) { + sink.add(_onWindowEnd!(unmodifiableQueue)); + } + + queue.clear(); + _windowSubscription?.cancel(); + _windowSubscription = null; + + sink.close(); + return; + } + + if (isControllerClosing || + _strategy == WindowStrategy.eventAfterLastWindow || + _strategy == WindowStrategy.everyEvent) { + _windowSubscription?.cancel(); + _windowSubscription = null; + } + + if (isControllerClosing && !_dispatchOnClose) { + return; + } + + if (_hasData && (queue.isNotEmpty || !_ignoreEmptyWindows)) { + if (_onWindowEnd != null) { + sink.add(_onWindowEnd!(unmodifiableQueue)); + } + + // prepare the buffer for the next window. + // by default, this is just a cleared buffer + if (!isControllerClosing && _startBufferEvery > 0) { + skip = _startBufferEvery > queue.length + ? _startBufferEvery - queue.length + : 0; + + // ...unless startBufferEvery is provided. + // here we backtrack to the first event of the last buffer + // and count forward using startBufferEvery until we reach + // the next event. + // + // if the next event is found inside the current buffer, + // then this event and any later events in the buffer + // become the starting values of the next buffer. + // if the next event is not yet available, then a skip + // count is calculated. + // this count will skip the next Future n-events. + // when skip is reset to 0, then we start adding events + // again into the new buffer. + // + // example: + // startBufferEvery = 2 + // last buffer: [0, 1, 2, 3, 4] + // 0 is the first event, + // 2 is the n-th event + // new buffer starts with [2, 3, 4] + // + // example: + // startBufferEvery = 3 + // last buffer: [0, 1] + // 0 is the first event, + // the n-the event is not yet dispatched at this point + // skip becomes 1 + // event 2 is skipped, skip becomes 0 + // event 3 is now added to the buffer + if (_startBufferEvery < queue.length) { + queue.removeFirstElements(_startBufferEvery); + } else { + queue.clear(); + } + } else { + queue.clear(); + } + } + } + + List get unmodifiableQueue => List.unmodifiable(queue); +} + +/// A highly customizable [StreamTransformer] which can be configured +/// to serve any of the common rx backpressure operators. +/// +/// The [StreamTransformer] works by creating windows, during which it +/// buffers events to a [Queue]. +/// +/// The [StreamTransformer] works by creating windows, during which it +/// buffers events to a [Queue]. It uses a [WindowStrategy] to determine +/// how and when a new window is created. +/// +/// onWindowStart and onWindowEnd are handlers that fire when a window +/// opens and closes, right before emitting the transformed event. +/// +/// startBufferEvery allows to skip events coming from the source [Stream]. +/// +/// ignoreEmptyWindows can be set to true, to allow events to be emitted +/// at the end of a window, even if the current buffer is empty. +/// If the buffer is empty, then an empty [List] will be emitted. +/// If false, then nothing is emitted on an empty buffer. +/// +/// dispatchOnClose will cause the remaining values in the buffer to be +/// emitted when the source [Stream] closes. +/// When false, the remaining buffer is discarded on close. +class BackpressureStreamTransformer extends StreamTransformerBase { + /// Determines how the window is created + final WindowStrategy strategy; + + /// Factory method used to create the [Stream] which will be buffered + final Stream Function(S event)? windowStreamFactory; + + /// Handler which fires when the window opens + final T Function(S event)? onWindowStart; + + /// Handler which fires when the window closes + final T Function(List queue)? onWindowEnd; + + /// Maximum length of the buffer. + /// Specify this value to avoid running out of memory when adding too many events to the buffer. + /// If it's `null`, maximum length of the buffer is unlimited. + final int? maxLengthQueue; + + /// Used to skip an amount of events + final int startBufferEvery; + + /// Predicate which determines when the current window should close + final bool Function(List queue)? closeWindowWhen; + + /// Toggle to prevent, or allow windows that contain + /// no events to be dispatched + final bool ignoreEmptyWindows; + + /// Toggle to prevent, or allow the final set of events to be dispatched + /// when the source [Stream] closes + final bool dispatchOnClose; + + /// Constructs a [StreamTransformer] which buffers events emitted by the + /// [Stream] that is created by [windowStreamFactory]. + /// + /// Use the various optional parameters to precisely determine how and when + /// this buffer should be created. + /// + /// For more info on the parameters, see [BackpressureStreamTransformer], + /// or see the various back pressure [StreamTransformer]s for examples. + BackpressureStreamTransformer( + this.strategy, + this.windowStreamFactory, { + this.onWindowStart, + this.onWindowEnd, + this.startBufferEvery = 0, + this.closeWindowWhen, + this.ignoreEmptyWindows = true, + this.dispatchOnClose = true, + this.maxLengthQueue, + }); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _BackpressureStreamSink( + strategy, + windowStreamFactory, + onWindowStart, + onWindowEnd, + startBufferEvery, + closeWindowWhen, + ignoreEmptyWindows, + dispatchOnClose, + maxLengthQueue, + ), + ); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/buffer.dart b/sandbox/reactivex/lib/src/transformers/backpressure/buffer.dart new file mode 100644 index 0000000..1b6044b --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/buffer.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Creates a [Stream] where each item is a [List] containing the items +/// from the source sequence. +/// +/// This [List] is emitted every time the window [Stream] +/// emits an event. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (i) => i) +/// .buffer(Stream.periodic(const Duration(milliseconds: 160), (i) => i)) +/// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... +class BufferStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever [window] fires an event. + /// + /// The [List] is cleared upon every [window] event. + BufferStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => queue, ignoreEmptyWindows: false); +} + +/// Buffers a number of values from the source Stream by count then +/// emits the buffer and clears it, and starts a new buffer each +/// startBufferEvery values. If startBufferEvery is not provided, +/// then new buffers are started immediately at the start of the source +/// and when each buffer closes and is emitted. +/// +/// ### Example +/// count is the maximum size of the buffer emitted +/// +/// Rx.range(1, 4) +/// .bufferCount(2) +/// .listen(print); // prints [1, 2], [3, 4] done! +/// +/// ### Example +/// if startBufferEvery is 2, then a new buffer will be started +/// on every other value from the source. A new buffer is started at the +/// beginning of the source by default. +/// +/// Rx.range(1, 5) +/// .bufferCount(3, 2) +/// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! +class BufferCountStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever its length is equal to [count]. + /// + /// A new buffer is created for every n-th event emitted + /// by the [Stream] that is being transformed, as specified by + /// the [startBufferEvery] parameter. + /// + /// If [startBufferEvery] is omitted or equals 0, then a new buffer is started whenever + /// the previous one reaches a length of [count]. + BufferCountStreamTransformer(int count, [int startBufferEvery = 0]) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => queue, + startBufferEvery: startBufferEvery, + closeWindowWhen: (queue) => queue.length == count) { + if (count < 1) throw ArgumentError.value(count, 'count'); + if (startBufferEvery < 0) { + throw ArgumentError.value(startBufferEvery, 'startBufferEvery'); + } + } +} + +/// Creates a [Stream] where each item is a [List] containing the items +/// from the source sequence, batched whenever test passes. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (int i) => i) +/// .bufferTest((i) => i % 2 == 0) +/// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... +class BufferTestStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever the [test] Function yields true. + BufferTestStreamTransformer(bool Function(T value) test) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => queue, + closeWindowWhen: (queue) => test(queue.last)); +} + +/// Extends the Stream class with the ability to buffer events in various ways +extension BufferExtensions on Stream { + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence. + /// + /// This [List] is emitted every time [window] emits an event. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (i) => i) + /// .buffer(Stream.periodic(Duration(milliseconds: 160), (i) => i)) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> buffer(Stream window) => + BufferStreamTransformer((_) => window).bind(this); + + /// Buffers a number of values from the source Stream by [count] then + /// emits the buffer and clears it, and starts a new buffer each + /// [startBufferEvery] values. If [startBufferEvery] is not provided, + /// then new buffers are started immediately at the start of the source + /// and when each buffer closes and is emitted. + /// + /// ### Example + /// [count] is the maximum size of the buffer emitted + /// + /// RangeStream(1, 4) + /// .bufferCount(2) + /// .listen(print); // prints [1, 2], [3, 4] done! + /// + /// ### Example + /// if [startBufferEvery] is 2, then a new buffer will be started + /// on every other value from the source. A new buffer is started at the + /// beginning of the source by default. + /// + /// RangeStream(1, 5) + /// .bufferCount(3, 2) + /// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! + Stream> bufferCount(int count, [int startBufferEvery = 0]) => + BufferCountStreamTransformer(count, startBufferEvery).bind(this); + + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence, batched whenever test passes. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .bufferTest((i) => i % 2 == 0) + /// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... + Stream> bufferTest(bool Function(T event) onTestHandler) => + BufferTestStreamTransformer(onTestHandler).bind(this); + + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence, sampled on a time frame with [duration]. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .bufferTime(Duration(milliseconds: 220)) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> bufferTime(Duration duration) => + buffer(Stream.periodic(duration)); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/debounce.dart b/sandbox/reactivex/lib/src/transformers/backpressure/debounce.dart new file mode 100644 index 0000000..b9e9a9e --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/debounce.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/timer.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Transforms a [Stream] so that will only emit items from the source sequence +/// if a window has completed, without the source sequence emitting +/// another item. +/// +/// This window is created after the last debounced event was emitted. +/// You can use the value of the last debounced event to determine +/// the length of the next window. +/// +/// A window is open until the first window event emits. +/// +/// The debounce [StreamTransformer] filters out items emitted by the source +/// Stream that are rapidly followed by another emitted item. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#debounce) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .debounceTime(Duration(seconds: 1)) +/// .listen(print); // prints 4 +class DebounceStreamTransformer extends BackpressureStreamTransformer { + /// Constructs a [StreamTransformer] which will only emit items from the source sequence + /// if a window has completed, without the source sequence emitting. + /// + /// The [window] is reset whenever the [Stream] that is being transformed + /// emits an event. + DebounceStreamTransformer(Stream Function(T event) window) + : super( + WindowStrategy.everyEvent, + window, + onWindowEnd: (queue) => queue.last, + maxLengthQueue: 1, + ); +} + +/// Extends the Stream class with the ability to debounce events in various ways +extension DebounceExtensions on Stream { + /// Transforms a [Stream] so that will only emit items from the source sequence + /// if a [window] has completed, without the source sequence emitting + /// another item. + /// + /// This [window] is created after the last debounced event was emitted. + /// You can use the value of the last debounced event to determine + /// the length of the next [window]. + /// + /// A [window] is open until the first [window] event emits. + /// + /// debounce filters out items emitted by the source [Stream] + /// that are rapidly followed by another emitted item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#debounce) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .debounce((_) => TimerStream(true, Duration(seconds: 1))) + /// .listen(print); // prints 4 + Stream debounce(Stream Function(T event) window) => + DebounceStreamTransformer(window).bind(this); + + /// Transforms a [Stream] so that will only emit items from the source + /// sequence whenever the time span defined by [duration] passes, without the + /// source sequence emitting another item. + /// + /// This time span start after the last debounced event was emitted. + /// + /// debounceTime filters out items emitted by the source [Stream] that are + /// rapidly followed by another emitted item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#debounceTime) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .debounceTime(Duration(seconds: 1)) + /// .listen(print); // prints 4 + Stream debounceTime(Duration duration) => + DebounceStreamTransformer((_) => TimerStream(null, duration)) + .bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/pairwise.dart b/sandbox/reactivex/lib/src/transformers/backpressure/pairwise.dart new file mode 100644 index 0000000..fa2320e --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/pairwise.dart @@ -0,0 +1,35 @@ +import 'package:angel3_reactivex/src/streams/never.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Emits the n-th and n-1th events as a pair. +/// The first event won't be emitted until the second one arrives. +/// +/// ### Example +/// +/// Rx.range(1, 4) +/// .pairwise() +/// .listen(print); // prints [1, 2], [2, 3], [3, 4] +class PairwiseStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into pairs as a [List]. + PairwiseStreamTransformer() + : super(WindowStrategy.firstEventOnly, (_) => NeverStream(), + onWindowEnd: (queue) => queue, + startBufferEvery: 1, + closeWindowWhen: (queue) => queue.length == 2, + dispatchOnClose: false); +} + +/// Extends the Stream class with the ability to emit the nth and n-1th events +/// as a pair +extension PairwiseExtension on Stream { + /// Emits the n-th and n-1th events as a pair. + /// The first event won't be emitted until the second one arrives. + /// + /// ### Example + /// + /// RangeStream(1, 4) + /// .pairwise() + /// .listen(print); // prints [1, 2], [2, 3], [3, 4] + Stream> pairwise() => PairwiseStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/sample.dart b/sandbox/reactivex/lib/src/transformers/backpressure/sample.dart new file mode 100644 index 0000000..b2c5545 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/sample.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// A [StreamTransformer] that, when the specified window [Stream] emits +/// an item or completes, emits the most recently emitted item (if any) +/// emitted by the source [Stream] since the previous emission from +/// the sample [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(SampleStreamTransformer(TimerStream(1, const Duration(seconds: 1))) +/// .listen(print); // prints 3 +class SampleStreamTransformer extends BackpressureStreamTransformer { + /// Constructs a [StreamTransformer] that, when the specified [window] emits + /// an item or completes, emits the most recently emitted item (if any) + /// emitted by the source [Stream] since the previous emission from + /// the sample [Stream]. + SampleStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => queue.last); +} + +/// Extends the Stream class with the ability to sample events from the Stream +extension SampleExtensions on Stream { + /// Emits the most recently emitted item (if any) + /// emitted by the source [Stream] since the previous emission from + /// the [sampleStream]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .sample(TimerStream(1, Duration(seconds: 1))) + /// .listen(print); // prints 3 + Stream sample(Stream sampleStream) => + SampleStreamTransformer((_) => sampleStream).bind(this); + + /// Emits the most recently emitted item (if any) emitted by the source + /// [Stream] since the previous emission within the recurring time span, + /// defined by [duration] + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .sampleTime(Duration(seconds: 1)) + /// .listen(print); // prints 3 + Stream sampleTime(Duration duration) => + sample(Stream.periodic(duration)); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/throttle.dart b/sandbox/reactivex/lib/src/transformers/backpressure/throttle.dart new file mode 100644 index 0000000..523c180 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/throttle.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/timer.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// A [StreamTransformer] that emits a value from the source [Stream], +/// then ignores subsequent source values while the window [Stream] is open, +/// then repeats this process. +/// +/// If leading is true, then the first item in each window is emitted. +/// If trailing is true, then the last item in each window is emitted. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(ThrottleStreamTransformer((_) => TimerStream(true, const Duration(seconds: 1)))) +/// .listen(print); // prints 1 +class ThrottleStreamTransformer extends BackpressureStreamTransformer { + /// Construct a [StreamTransformer] that emits a value from the source [Stream], + /// then ignores subsequent source values while the window [Stream] is open, + /// then repeats this process. + /// + /// If [leading] is true, then the first item in each window is emitted. + /// If [trailing] is true, then the last item in each window is emitted. + ThrottleStreamTransformer( + Stream Function(T event) window, { + bool trailing = false, + bool leading = true, + }) : super( + WindowStrategy.eventAfterLastWindow, + window, + onWindowStart: leading ? (event) => event : null, + onWindowEnd: trailing ? (queue) => queue.last : null, + dispatchOnClose: trailing, + maxLengthQueue: trailing ? 2 : 0, + ); +} + +/// Extends the Stream class with the ability to throttle events in various ways +extension ThrottleExtensions on Stream { + /// Emits a value from the source [Stream], then ignores subsequent source values + /// while the window [Stream] is open, then repeats this process. + /// + /// If leading is true, then the first item in each window is emitted. + /// If trailing is true, then the last item in each window is emitted. + /// + /// You can use the value of the last throttled event to determine the length + /// of the next [window]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .throttle((_) => TimerStream(true, Duration(seconds: 1))); + Stream throttle(Stream Function(T event) window, + {bool trailing = false, bool leading = true}) => + ThrottleStreamTransformer( + window, + trailing: trailing, + leading: leading, + ).bind(this); + + /// Emits a value from the source [Stream], then ignores subsequent source values + /// for a duration, then repeats this process. + /// + /// If leading is true, then the first item in each window is emitted. + /// If [trailing] is true, then the last item is emitted instead. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .throttleTime(Duration(seconds: 1)); + Stream throttleTime(Duration duration, + {bool trailing = false, bool leading = true}) => + ThrottleStreamTransformer( + (_) => TimerStream(true, duration), + trailing: trailing, + leading: leading, + ).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/backpressure/window.dart b/sandbox/reactivex/lib/src/transformers/backpressure/window.dart new file mode 100644 index 0000000..544a1b4 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/backpressure/window.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Creates a [Stream] where each item is a [Stream] containing the items +/// from the source sequence. +/// +/// This [List] is emitted every time the window [Stream] +/// emits an event. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (i) => i) +/// .window(Stream.periodic(const Duration(milliseconds: 160), (i) => i)) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... +class WindowStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever [window] fires an event. + /// + /// The [Stream] is recreated and starts empty upon every [window] event. + WindowStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => Stream.fromIterable(queue), + ignoreEmptyWindows: false); +} + +/// Buffers a number of values from the source Stream by count then emits the +/// buffer as a [Stream] and clears it, and starts a new buffer each +/// startBufferEvery values. If startBufferEvery is not provided, then new +/// buffers are started immediately at the start of the source and when each +/// buffer closes and is emitted. +/// +/// ### Example +/// count is the maximum size of the buffer emitted +/// +/// Rx.range(1, 4) +/// .windowCount(2) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [1, 2], [3, 4] done! +/// +/// ### Example +/// if startBufferEvery is 2, then a new buffer will be started +/// on every other value from the source. A new buffer is started at the +/// beginning of the source by default. +/// +/// Rx.range(1, 5) +/// .bufferCount(3, 2) +/// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! +class WindowCountStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever its length is equal to [count]. + /// + /// A new buffer is created for every n-th event emitted + /// by the [Stream] that is being transformed, as specified by + /// the [startBufferEvery] parameter. + /// + /// If [startBufferEvery] is omitted or equals 0, then a new buffer is started whenever + /// the previous one reaches a length of [count]. + WindowCountStreamTransformer(int count, [int startBufferEvery = 0]) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => Stream.fromIterable(queue), + startBufferEvery: startBufferEvery, + closeWindowWhen: (queue) => queue.length == count) { + if (count < 1) throw ArgumentError.value(count, 'count'); + if (startBufferEvery < 0) { + throw ArgumentError.value(startBufferEvery, 'startBufferEvery'); + } + } +} + +/// Creates a [Stream] where each item is a [Stream] containing the items +/// from the source sequence, batched whenever test passes. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (int i) => i) +/// .windowTest((i) => i % 2 == 0) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... +class WindowTestStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever the [test] Function yields true. + WindowTestStreamTransformer(bool Function(T value) test) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => Stream.fromIterable(queue), + closeWindowWhen: (queue) => test(queue.last)); +} + +/// Extends the Stream class with the ability to window +extension WindowExtensions on Stream { + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence. + /// + /// This [List] is emitted every time [window] emits an event. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (i) => i) + /// .window(Stream.periodic(Duration(milliseconds: 160), (i) => i)) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> window(Stream window) => + WindowStreamTransformer((_) => window).bind(this); + + /// Buffers a number of values from the source Stream by [count] then emits + /// the buffer as a [Stream] and clears it, and starts a new buffer each + /// [startBufferEvery] values. If [startBufferEvery] is not provided, then new + /// buffers are started immediately at the start of the source and when each + /// buffer closes and is emitted. + /// + /// ### Example + /// [count] is the maximum size of the buffer emitted + /// + /// RangeStream(1, 4) + /// .windowCount(2) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [1, 2], [3, 4] done! + /// + /// ### Example + /// if [startBufferEvery] is 2, then a new buffer will be started + /// on every other value from the source. A new buffer is started at the + /// beginning of the source by default. + /// + /// RangeStream(1, 5) + /// .bufferCount(3, 2) + /// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! + Stream> windowCount(int count, [int startBufferEvery = 0]) => + WindowCountStreamTransformer(count, startBufferEvery).bind(this); + + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence, batched whenever test passes. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .windowTest((i) => i % 2 == 0) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... + Stream> windowTest(bool Function(T event) onTestHandler) => + WindowTestStreamTransformer(onTestHandler).bind(this); + + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence, sampled on a time frame with [duration]. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .windowTime(Duration(milliseconds: 220)) + /// .doOnData((_) => print('next window')) + /// .flatMap((s) => s) + /// .listen(print); // prints next window 0, 1, next window 2, 3, ... + Stream> windowTime(Duration duration) => + window(Stream.periodic(duration)); +} diff --git a/sandbox/reactivex/lib/src/transformers/default_if_empty.dart b/sandbox/reactivex/lib/src/transformers/default_if_empty.dart new file mode 100644 index 0000000..fb9deed --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/default_if_empty.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +class _DefaultIfEmptyStreamSink implements EventSink { + final S _defaultValue; + final EventSink _outputSink; + bool _isEmpty = true; + + _DefaultIfEmptyStreamSink(this._outputSink, this._defaultValue); + + @override + void add(S data) { + _isEmpty = false; + _outputSink.add(data); + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + if (_isEmpty) { + _outputSink.add(_defaultValue); + } + + _outputSink.close(); + } +} + +/// Emit items from the source [Stream], or a single default item if the source +/// Stream emits nothing. +/// +/// ### Example +/// +/// Stream.empty() +/// .transform(DefaultIfEmptyStreamTransformer(10)) +/// .listen(print); // prints 10 +class DefaultIfEmptyStreamTransformer extends StreamTransformerBase { + /// The event that should be emitted if the source [Stream] is empty + final S defaultValue; + + /// Constructs a [StreamTransformer] which either emits from the source [Stream], + /// or just a [defaultValue] if the source [Stream] emits nothing. + DefaultIfEmptyStreamTransformer(this.defaultValue); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _DefaultIfEmptyStreamSink(sink, defaultValue)); +} + +/// +extension DefaultIfEmptyExtension on Stream { + /// Emit items from the source Stream, or a single default item if the source + /// Stream emits nothing. + /// + /// ### Example + /// + /// Stream.empty().defaultIfEmpty(10).listen(print); // prints 10 + Stream defaultIfEmpty(T defaultValue) => + DefaultIfEmptyStreamTransformer(defaultValue).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/delay.dart b/sandbox/reactivex/lib/src/transformers/delay.dart new file mode 100644 index 0000000..f2fd2b9 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/delay.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _DelayStreamSink extends ForwardingSink { + final Duration _duration; + var _inputClosed = false; + final _subscriptions = Queue>(); + + _DelayStreamSink(this._duration); + + @override + void onData(S data) { + final subscription = Rx.timer(null, _duration).listen((_) { + _subscriptions.removeFirst(); + + sink.add(data); + + if (_inputClosed && _subscriptions.isEmpty) { + sink.close(); + } + }); + + _subscriptions.addLast(subscription); + } + + @override + void onError(Object error, StackTrace st) => sink.addError(error, st); + + @override + void onDone() { + _inputClosed = true; + + if (_subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() => _subscriptions.cancelAll(); + + @override + void onListen() {} + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// The Delay operator modifies its source Stream by pausing for +/// a particular increment of time (that you specify) before emitting +/// each of the source Stream’s items. +/// This has the effect of shifting the entire sequence of items emitted +/// by the Stream forward in time by that specified increment. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#delay) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .delay(Duration(seconds: 1)) +/// .listen(print); // [after one second delay] prints 1, 2, 3, 4 immediately +class DelayStreamTransformer extends StreamTransformerBase { + /// The delay used to pause initial emission of events by + final Duration duration; + + /// Constructs a [StreamTransformer] which will first pause for [duration] of time, + /// before submitting events from the source [Stream]. + DelayStreamTransformer(this.duration); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _DelayStreamSink(duration)); +} + +/// Extends the Stream class with the ability to delay events being emitted +extension DelayExtension on Stream { + /// The Delay operator modifies its source Stream by pausing for a particular + /// increment of time (that you specify) before emitting each of the source + /// Stream’s items. This has the effect of shifting the entire sequence of + /// items emitted by the Stream forward in time by that specified increment. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#delay) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .delay(Duration(seconds: 1)) + /// .listen(print); // [after one second delay] prints 1, 2, 3, 4 immediately + Stream delay(Duration duration) => + DelayStreamTransformer(duration).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/delay_when.dart b/sandbox/reactivex/lib/src/transformers/delay_when.dart new file mode 100644 index 0000000..5b80eb0 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/delay_when.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _DelayWhenStreamSink extends ForwardingSink { + final Stream Function(T) itemDelaySelector; + final Stream? listenDelay; + + final subscriptions = >[]; + StreamSubscription? delaySubscription; + var closed = false; + + _DelayWhenStreamSink(this.itemDelaySelector, this.listenDelay); + + @override + void onData(T data) { + final subscription = + itemDelaySelector(data).take(1).listen(null, onError: sink.addError); + + subscription.onDone(() { + subscriptions.remove(subscription); + + sink.add(data); + if (subscriptions.isEmpty && closed) { + sink.close(); + } + }); + + subscriptions.add(subscription); + } + + @override + void onError(Object error, StackTrace st) => sink.addError(error, st); + + @override + void onDone() { + closed = true; + if (subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() { + final future = delaySubscription?.cancel(); + delaySubscription = null; + + if (subscriptions.isEmpty) { + return future; + } + + final futures = [ + for (final s in subscriptions) s.cancel(), + if (future != null) future, + ]; + subscriptions.clear(); + + return waitFuturesList(futures); + } + + @override + FutureOr onListen() { + if (listenDelay == null) { + return null; + } + + final completer = Completer.sync(); + delaySubscription = listenDelay!.take(1).listen( + null, + onError: (Object e, StackTrace s) { + delaySubscription?.cancel(); + delaySubscription = null; + completer.completeError(e, s); + }, + onDone: () { + delaySubscription?.cancel(); + delaySubscription = null; + completer.complete(null); + }, + ); + return completer.future; + } + + @override + void onPause() { + delaySubscription?.pause(); + subscriptions.pauseAll(); + } + + @override + void onResume() { + delaySubscription?.resume(); + subscriptions.resumeAll(); + } +} + +/// Delays the emission of items from the source [Stream] by a given time span +/// determined by the emissions of another [Stream]. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#delayWhen) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(DelayWhenStreamTransformer( +/// (i) => Rx.timer(null, Duration(seconds: i)))) +/// .listen(print); // [after 1s] prints 1 [after 1s] prints 2 [after 1s] prints 3 +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform( +/// DelayWhenStreamTransformer( +/// (i) => Rx.timer(null, Duration(seconds: i)), +/// listenDelay: Rx.timer(null, Duration(seconds: 2)), +/// ), +/// ) +/// .listen(print); // [after 3s] prints 1 [after 1s] prints 2 [after 1s] prints 3 +class DelayWhenStreamTransformer extends StreamTransformerBase { + /// A function used to determine delay time span for each data event. + final Stream Function(T value) itemDelaySelector; + + /// When [listenDelay] emits its first data or done event, the source Stream is listen to. + final Stream? listenDelay; + + /// Constructs a [StreamTransformer] which delays the emission of items + /// from the source [Stream] by a given time span determined by the emissions of another [Stream]. + DelayWhenStreamTransformer(this.itemDelaySelector, {this.listenDelay}); + + @override + Stream bind(Stream stream) => forwardStream( + stream, () => _DelayWhenStreamSink(itemDelaySelector, listenDelay)); +} + +/// Extends the Stream class with the ability to delay events being emitted. +extension DelayWhenExtension on Stream { + /// Delays the emission of items from the source [Stream] by a given time span + /// determined by the emissions of another [Stream]. + /// + /// When the source emits a data element, the `itemDelaySelector` function is called + /// with the data element as argument, and return a "duration" Stream. + /// The source element is emitted on the output Stream only when the "duration" Stream + /// emits a data or done event. + /// + /// Optionally, `delayWhen` takes a second argument `listenDelay`. When `listenDelay` + /// emits its first data or done event, the source Stream is listen to. + /// If `listenDelay` is not provided, `delayWhen` will listen to the source Stream + /// as soon as the output Stream is listen. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#delayWhen) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .delayWhen((i) => Rx.timer(null, Duration(seconds: i))) + /// .listen(print); // [after 1s] prints 1 [after 1s] prints 2 [after 1s] prints 3 + /// + /// Stream.fromIterable([1, 2, 3]) + /// .delayWhen( + /// (i) => Rx.timer(null, Duration(seconds: i)), + /// listenDelay: Rx.timer(null, Duration(seconds: 2)), + /// ) + /// .listen(print); // [after 3s] prints 1 [after 1s] prints 2 [after 1s] prints 3 + Stream delayWhen( + Stream Function(T value) itemDelaySelector, { + Stream? listenDelay, + }) => + DelayWhenStreamTransformer(itemDelaySelector, listenDelay: listenDelay) + .bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/dematerialize.dart b/sandbox/reactivex/lib/src/transformers/dematerialize.dart new file mode 100644 index 0000000..685b8ce --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/dematerialize.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _DematerializeStreamSink implements EventSink> { + final EventSink _outputSink; + + _DematerializeStreamSink(this._outputSink); + + @override + void add(StreamNotification data) => data.when( + data: _outputSink.add, + done: _outputSink.close, + error: _outputSink.addErrorAndStackTrace, + ); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Converts the onData, onDone, and onError [StreamNotification] objects from a +/// materialized stream into normal onData, onDone, and onError events. +/// +/// When a stream has been materialized, it emits onData, onDone, and onError +/// events as [StreamNotification] objects. Dematerialize simply reverses this by +/// transforming [StreamNotification] objects back to a normal stream of events. +/// +/// ### Example +/// +/// Stream> +/// .fromIterable([StreamNotification.data(1), StreamNotification.done()]) +/// .transform(DematerializeStreamTransformer()) +/// .listen(print); // Prints 1 +/// +/// ### Error example +/// +/// Stream> +/// .fromIterable([StreamNotification.error(Exception(), null)]) +/// .transform(DematerializeStreamTransformer()) +/// .listen(null, onError: (e, s) => print(e)); // Prints Exception +class DematerializeStreamTransformer + extends StreamTransformerBase, S> { + /// Constructs a [StreamTransformer] which converts the onData, onDone, and + /// onError [StreamNotification] objects from a materialized stream into normal + /// onData, onDone, and onError events. + DematerializeStreamTransformer(); + + @override + Stream bind(Stream> stream) => + Stream.eventTransformed(stream, (sink) => _DematerializeStreamSink(sink)); +} + +/// Converts the onData, onDone, and onError [StreamNotification]s from a +/// materialized stream into normal onData, onDone, and onError events. +extension DematerializeExtension on Stream> { + /// Converts the onData, onDone, and onError [StreamNotification] objects from a + /// materialized stream into normal onData, onDone, and onError events. + /// + /// When a stream has been materialized, it emits onData, onDone, and onError + /// events as [StreamNotification] objects. Dematerialize simply reverses this by + /// transforming [StreamNotification] objects back to a normal stream of events. + /// + /// ### Example + /// + /// Stream> + /// .fromIterable([StreamNotification.data(1), StreamNotification.done()]) + /// .dematerialize() + /// .listen(print); // Prints 1 + /// + /// ### Error example + /// + /// Stream> + /// .fromIterable([StreamNotification.error(Exception(), null)]) + /// .dematerialize() + /// .listen(null, onError: (e, s) => print(e)); // Prints Exception + Stream dematerialize() => DematerializeStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/distinct_unique.dart b/sandbox/reactivex/lib/src/transformers/distinct_unique.dart new file mode 100644 index 0000000..f3785df --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/distinct_unique.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:collection'; + +class _DistinctUniqueStreamSink implements EventSink { + final EventSink _outputSink; + final HashSet _collection; + + _DistinctUniqueStreamSink(this._outputSink, + {bool Function(S e1, S e2)? equals, int Function(S e)? hashCodeMethod}) + : _collection = HashSet(equals: equals, hashCode: hashCodeMethod); + + @override + void add(S data) { + if (_collection.add(data)) { + _outputSink.add(data); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _collection.clear(); + _outputSink.close(); + } +} + +/// Create a [Stream] which implements a [HashSet] under the hood, using +/// the provided `equals` as equality. +/// +/// The [Stream] will only emit an event, if that event is not yet found +/// within the underlying [HashSet]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 1, 2, 1, 2, 3, 2, 1]) +/// .listen((event) => print(event)); +/// +/// will emit: +/// 1, 2, 3 +/// +/// The provided `equals` must define a stable equivalence relation, and +/// `hashCode` must be consistent with `equals`. +/// +/// If `equals` or `hashCode` are omitted, the set uses the elements' intrinsic +/// `Object.==` and `Object.hashCode`. If you supply one of `equals` and +/// `hashCode`, you should generally also to supply the other. +class DistinctUniqueStreamTransformer extends StreamTransformerBase { + /// Optional method which determines equality between two events + final bool Function(S e1, S e2)? equals; + + /// Optional method which is used to create a hash from an event + final int Function(S e)? hashCodeMethod; + + /// Constructs a [StreamTransformer] which emits events from the source + /// [Stream] as if they were processed through a [HashSet]. + /// + /// See [HashSet] for a more detailed explanation. + DistinctUniqueStreamTransformer({this.equals, this.hashCodeMethod}); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, + (sink) => _DistinctUniqueStreamSink(sink, + equals: equals, hashCodeMethod: hashCodeMethod)); +} + +/// Extends the Stream class with the ability to skip items that have previously +/// been emitted. +extension DistinctUniqueExtension on Stream { + /// WARNING: More commonly known as distinct in other Rx implementations. + /// Creates a Stream where data events are skipped if they have already + /// been emitted before. + /// + /// Equality is determined by the provided equals and hashCode methods. + /// If these are omitted, the '==' operator and hashCode on the last provided + /// data element are used. + /// + /// The returned stream is a broadcast stream if this stream is. If a + /// broadcast stream is listened to more than once, each subscription will + /// individually perform the equals and hashCode tests. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#distinct) + Stream distinctUnique({ + bool Function(T e1, T e2)? equals, + int Function(T e)? hashCode, + }) => + DistinctUniqueStreamTransformer( + equals: equals, hashCodeMethod: hashCode) + .bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/do.dart b/sandbox/reactivex/lib/src/transformers/do.dart new file mode 100644 index 0000000..637505e --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/do.dart @@ -0,0 +1,305 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _DoStreamSink extends ForwardingSink { + final FutureOr Function()? _onCancel; + final void Function(S event)? _onData; + final void Function()? _onDone; + final void Function(StreamNotification notification)? _onEach; + final void Function(Object, StackTrace)? _onError; + final void Function()? _onListen; + final void Function()? _onPause; + final void Function()? _onResume; + + _DoStreamSink( + this._onCancel, + this._onData, + this._onDone, + this._onEach, + this._onError, + this._onListen, + this._onPause, + this._onResume, + ); + + @override + void onData(S data) { + try { + _onData?.call(data); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.data(data)); + } catch (e, s) { + sink.addError(e, s); + } + sink.add(data); + } + + @override + void onError(Object e, StackTrace st) { + try { + _onError?.call(e, st); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.error(e, st)); + } catch (e, s) { + sink.addError(e, s); + } + sink.addError(e, st); + } + + @override + void onDone() { + try { + _onDone?.call(); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.done()); + } catch (e, s) { + sink.addError(e, s); + } + sink.close(); + } + + @override + FutureOr onCancel() => _onCancel?.call(); + + @override + void onListen() { + try { + _onListen?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } + + @override + void onPause() { + try { + _onPause?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } + + @override + void onResume() { + try { + _onResume?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } +} + +/// Invokes the given callback at the corresponding point the the stream +/// lifecycle. For example, if you pass in an onDone callback, it will +/// be invoked when the stream finishes emitting items. +/// +/// This transformer can be used for debugging, logging, etc. by intercepting +/// the stream at different points to run arbitrary actions. +/// +/// It is possible to hook onto the following parts of the stream lifecycle: +/// +/// - onCancel +/// - onData +/// - onDone +/// - onError +/// - onListen +/// - onPause +/// - onResume +/// +/// In addition, the `onEach` argument is called at `onData`, `onDone`, and +/// `onError` with a [StreamNotification] passed in. The [StreamNotification] argument +/// contains the [NotificationKind] of event (OnData, OnDone, OnError), and the item or +/// error that was emitted. In the case of onDone, no data is emitted as part +/// of the [StreamNotification]. +/// +/// If no callbacks are passed in, a runtime error will be thrown in dev mode +/// in order to 'fail fast' and alert the developer that the transformer should +/// be used or safely removed. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(DoStreamTransformer( +/// onData: print, +/// onError: (e, s) => print('Oh no!'), +/// onDone: () => print('Done'))) +/// .listen(null); // Prints: 1, 'Done' +class DoStreamTransformer extends StreamTransformerBase { + /// fires when all subscriptions have cancelled. + final FutureOr Function()? onCancel; + + /// fires when data is emitted + final void Function(S event)? onData; + + /// fires on close + final void Function()? onDone; + + /// fires on data, close and error + final void Function(StreamNotification notification)? onEach; + + /// fires on errors + final void Function(Object, StackTrace)? onError; + + /// fires when a subscription first starts + final void Function()? onListen; + + /// fires when the subscription pauses + final void Function()? onPause; + + /// fires when the subscription resumes + final void Function()? onResume; + + /// Constructs a [StreamTransformer] which will trigger any of the provided + /// handlers as they occur. + DoStreamTransformer( + {this.onCancel, + this.onData, + this.onDone, + this.onEach, + this.onError, + this.onListen, + this.onPause, + this.onResume}) { + if (onCancel == null && + onData == null && + onDone == null && + onEach == null && + onError == null && + onListen == null && + onPause == null && + onResume == null) { + throw ArgumentError('Must provide at least one handler'); + } + } + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _DoStreamSink( + onCancel, + onData, + onDone, + onEach, + onError, + onListen, + onPause, + onResume, + ), + true, + ); +} + +/// Extends the Stream class with the ability to execute a callback function +/// at different points in the Stream's lifecycle. +extension DoExtensions on Stream { + /// Invokes the given callback function when the stream subscription is + /// cancelled. Often called doOnUnsubscribe or doOnDispose in other + /// implementations. + /// + /// ### Example + /// + /// final subscription = TimerStream(1, Duration(minutes: 1)) + /// .doOnCancel(() => print('hi')) + /// .listen(null); + /// + /// subscription.cancel(); // prints 'hi' + Stream doOnCancel(FutureOr Function() onCancel) => + DoStreamTransformer(onCancel: onCancel).bind(this); + + /// Invokes the given callback function when the stream emits an item. In + /// other implementations, this is called doOnNext. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .doOnData(print) + /// .listen(null); // prints 1, 2, 3 + Stream doOnData(void Function(T event) onData) => + DoStreamTransformer(onData: onData).bind(this); + + /// Invokes the given callback function when the stream finishes emitting + /// items. In other implementations, this is called doOnComplete(d). + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .doOnDone(() => print('all set')) + /// .listen(null); // prints 'all set' + Stream doOnDone(void Function() onDone) => + DoStreamTransformer(onDone: onDone).bind(this); + + /// Invokes the given callback function when the stream emits data, emits + /// an error, or emits done. The callback receives a [StreamNotification] object. + /// + /// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, + /// or OnError), and the item or error that was emitted. In the case of + /// onDone, no data is emitted as part of the [StreamNotification]. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .doOnEach(print) + /// .listen(null); // Prints DataNotification{value: 1}, DoneNotification{} + Stream doOnEach( + void Function(StreamNotification notification) onEach) => + DoStreamTransformer(onEach: onEach).bind(this); + + /// Invokes the given callback function when the stream emits an error. + /// + /// ### Example + /// + /// Stream.error(Exception()) + /// .doOnError((error, stacktrace) => print('oh no')) + /// .listen(null); // prints 'Oh no' + Stream doOnError(void Function(Object, StackTrace) onError) => + DoStreamTransformer(onError: onError).bind(this); + + /// Invokes the given callback function when the stream is first listened to. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .doOnListen(() => print('Is someone there?')) + /// .listen(null); // prints 'Is someone there?' + Stream doOnListen(void Function() onListen) => + DoStreamTransformer(onListen: onListen).bind(this); + + /// Invokes the given callback function when the stream subscription is + /// paused. + /// + /// ### Example + /// + /// final subscription = Stream.fromIterable([1]) + /// .doOnPause(() => print('Gimme a minute please')) + /// .listen(null); + /// + /// subscription.pause(); // prints 'Gimme a minute please' + Stream doOnPause(void Function() onPause) => + DoStreamTransformer(onPause: onPause).bind(this); + + /// Invokes the given callback function when the stream subscription + /// resumes receiving items. + /// + /// ### Example + /// + /// final subscription = Stream.fromIterable([1]) + /// .doOnResume(() => print('Let's do this!')) + /// .listen(null); + /// + /// subscription.pause(); + /// subscription.resume(); 'Let's do this!' + Stream doOnResume(void Function() onResume) => + DoStreamTransformer(onResume: onResume).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/end_with.dart b/sandbox/reactivex/lib/src/transformers/end_with.dart new file mode 100644 index 0000000..c4f99ee --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/end_with.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _EndWithStreamSink implements EventSink { + final S _endValue; + final EventSink _outputSink; + + _EndWithStreamSink(this._outputSink, this._endValue); + + @override + void add(S data) => _outputSink.add(data); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _outputSink.add(_endValue); + _outputSink.close(); + } +} + +/// Appends a value to the source [Stream] before closing. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(EndWithStreamTransformer(1)) +/// .listen(print); // prints 2, 1 +class EndWithStreamTransformer extends StreamTransformerBase { + /// The ending event of this [Stream] + final S endValue; + + /// Constructs a [StreamTransformer] which appends the source [Stream] + /// with [endValue] just before it closes. + EndWithStreamTransformer(this.endValue); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _EndWithStreamSink(sink, endValue)); +} + +/// Extends the [Stream] class with the ability to emit the given value as the +/// final item before closing. +extension EndWithExtension on Stream { + /// Appends a value to the source [Stream] before closing. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).endWith(1).listen(print); // prints 2, 1 + Stream endWith(T endValue) => + EndWithStreamTransformer(endValue).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/end_with_many.dart b/sandbox/reactivex/lib/src/transformers/end_with_many.dart new file mode 100644 index 0000000..050e456 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/end_with_many.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _EndWithManyStreamSink implements EventSink { + final Iterable _endValues; + final EventSink _outputSink; + + _EndWithManyStreamSink(this._outputSink, this._endValues); + + @override + void add(S data) => _outputSink.add(data); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _endValues.forEach(_outputSink.add); + _outputSink.close(); + } +} + +/// Appends a sequence of values to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([3]) +/// .transform(EndWithManyStreamTransformer([1, 2])) +/// .listen(print); // prints 3, 1, 2 +class EndWithManyStreamTransformer extends StreamTransformerBase { + /// The ending events of this [Stream] + final Iterable endValues; + + /// Constructs a [StreamTransformer] which appends the source [Stream] + /// with [endValues] before closing. + EndWithManyStreamTransformer(this.endValues); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _EndWithManyStreamSink(sink, endValues)); +} + +/// Extends the Stream class with the ability to emit the given value as the +/// final item before closing. +extension EndWithManyExtension on Stream { + /// Appends a sequence of values as final events to the source [Stream] before closing. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).endWithMany([1, 0]).listen(print); // prints 2, 1, 0 + Stream endWithMany(Iterable endValues) => + EndWithManyStreamTransformer(endValues).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/exhaust_map.dart b/sandbox/reactivex/lib/src/transformers/exhaust_map.dart new file mode 100644 index 0000000..d9c9bac --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/exhaust_map.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _ExhaustMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + StreamSubscription? _mapperSubscription; + bool _inputClosed = false; + + _ExhaustMapStreamSink(this._mapper); + + @override + void onData(S data) { + if (_mapperSubscription != null) { + return; + } + + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + _mapperSubscription = mappedStream.listen( + sink.add, + onError: sink.addError, + onDone: () { + _mapperSubscription = null; + + if (_inputClosed) { + sink.close(); + } + }, + ); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + _mapperSubscription ?? sink.close(); + } + + @override + FutureOr onCancel() => _mapperSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _mapperSubscription?.pause(); + + @override + void onResume() => _mapperSubscription?.resume(); +} + +/// Converts events from the source stream into a new Stream using a given +/// mapper. It ignores all items from the source stream until the new stream +/// completes. +/// +/// Useful when you have a noisy source Stream and only want to respond once +/// the previous async operation is finished. +/// +/// ### Example +/// // Emits 0, 1, 2 +/// Stream.periodic(Duration(milliseconds: 200), (i) => i).take(3) +/// .transform(ExhaustMapStreamTransformer( +/// // Emits the value it's given after 200ms +/// (i) => Rx.timer(i, Duration(milliseconds: 200)), +/// )) +/// .listen(print); // prints 0, 2 +class ExhaustMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Constructs a [StreamTransformer] which maps each event from the source [Stream] + /// using [mapper]. + /// + /// The mapped [Stream] will be be listened to and begin + /// emitting items, and any previously created mapper [Stream]s will stop emitting. + ExhaustMapStreamTransformer(this.mapper); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _ExhaustMapStreamSink(mapper)); +} + +/// Extends the Stream class with the ability to transform the Stream into +/// a new Stream. The new Stream emits items and ignores events from the source +/// Stream until the new Stream completes. +extension ExhaustMapExtension on Stream { + /// Converts items from the source stream into a Stream using a given + /// mapper. It ignores all items from the source stream until the new stream + /// completes. + /// + /// Useful when you have a noisy source Stream and only want to respond once + /// the previous async operation is finished. + /// + /// ### Example + /// + /// RangeStream(0, 2).interval(Duration(milliseconds: 50)) + /// .exhaustMap((i) => + /// TimerStream(i, Duration(milliseconds: 75))) + /// .listen(print); // prints 0, 2 + Stream exhaustMap(Stream Function(T value) mapper) => + ExhaustMapStreamTransformer(mapper).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/flat_map.dart b/sandbox/reactivex/lib/src/transformers/flat_map.dart new file mode 100644 index 0000000..c5dff2a --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/flat_map.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _FlatMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + final int? maxConcurrent; + + final List> _subscriptions = >[]; + final Queue queue = DoubleLinkedQueue(); + bool _inputClosed = false; + + _FlatMapStreamSink(this._mapper, this.maxConcurrent); + + @override + void onData(S data) { + if (maxConcurrent != null && _subscriptions.length >= maxConcurrent!) { + queue.addLast(data); + } else { + listenInner(data); + } + } + + void listenInner(S data) { + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + final subscription = mappedStream.listen(sink.add, onError: sink.addError); + subscription.onDone(() { + _subscriptions.remove(subscription); + + if (queue.isNotEmpty) { + listenInner(queue.removeFirst()); + } else if (_inputClosed && _subscriptions.isEmpty) { + sink.close(); + } + }); + _subscriptions.add(subscription); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + if (_subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() { + queue.clear(); + return _subscriptions.cancelAll(); + } + + @override + void onListen() {} + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// Converts each emitted item into a new Stream using the given mapper function, +/// while limiting the maximum number of concurrent subscriptions to these [Stream]s. +/// The newly created Stream will be listened to and begin emitting items downstream. +/// +/// The items emitted by each of the new Streams are emitted downstream in the +/// same order they arrive. In other words, the sequences are merged +/// together. +/// +/// ### Example +/// +/// Stream.fromIterable([4, 3, 2, 1]) +/// .transform(FlatMapStreamTransformer((i) => +/// Stream.fromFuture( +/// Future.delayed(Duration(minutes: i), () => i)) +/// .listen(print); // prints 1, 2, 3, 4 +class FlatMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Maximum number of inner [Stream] that may be listened to concurrently. + /// If it's `null`, it means unlimited. + final int? maxConcurrent; + + /// Constructs a [StreamTransformer] which emits events from the source [Stream] using the given [mapper]. + /// The mapped [Stream] will be listened to and begin emitting items downstream. + FlatMapStreamTransformer(this.mapper, {this.maxConcurrent}); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _FlatMapStreamSink(mapper, maxConcurrent)); +} + +/// Extends the Stream class with the ability to convert the source Stream into +/// a new Stream each time the source emits an item. +extension FlatMapExtension on Stream { + /// Converts each emitted item into a Stream using the given mapper function, + /// while limiting the maximum number of concurrent subscriptions to these [Stream]s. + /// The newly created Stream will be be listened to and begin emitting items downstream. + /// + /// The items emitted by each of the Streams are emitted downstream in the + /// same order they arrive. In other words, the sequences are merged + /// together. + /// + /// ### Example + /// + /// RangeStream(4, 1) + /// .flatMap((i) => TimerStream(i, Duration(minutes: i))) + /// .listen(print); // prints 1, 2, 3, 4 + Stream flatMap(Stream Function(T value) mapper, + {int? maxConcurrent}) => + FlatMapStreamTransformer(mapper, maxConcurrent: maxConcurrent) + .bind(this); + + /// Converts each item into a Stream. The Stream must return an + /// Iterable. Then, each item from the Iterable will be emitted one by one. + /// + /// Use case: you may have an API that returns a list of items, such as + /// a Stream>. However, you might want to operate on the individual items + /// rather than the list itself. This is the job of `flatMapIterable`. + /// + /// ### Example + /// + /// RangeStream(1, 4) + /// .flatMapIterable((i) => Stream.fromIterable([[i]])) + /// .listen(print); // prints 1, 2, 3, 4 + Stream flatMapIterable(Stream> Function(T value) mapper, + {int? maxConcurrent}) => + FlatMapStreamTransformer>(mapper, + maxConcurrent: maxConcurrent) + .bind(this) + .expand((Iterable iterable) => iterable); +} diff --git a/sandbox/reactivex/lib/src/transformers/group_by.dart b/sandbox/reactivex/lib/src/transformers/group_by.dart new file mode 100644 index 0000000..e2d2edb --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/group_by.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _GroupByStreamSink extends ForwardingSink> { + final K Function(T event) grouper; + final Stream Function(GroupedStream)? duration; + + final groups = >{}; + Map>? subscriptions; + + _GroupByStreamSink(this.grouper, this.duration); + + void _closeAll() { + for (var c in groups.values) { + c.close(); + } + groups.clear(); + } + + StreamController _controllerBuilder(K key) { + final groupedController = StreamController.broadcast(sync: true); + final groupByStream = GroupedStream(key, groupedController.stream); + + if (duration != null) { + subscriptions?.remove(key)?.cancel(); + (subscriptions ??= {})[key] = duration!(groupByStream).take(1).listen( + null, + onDone: () { + subscriptions!.remove(key); + groups.remove(key)?.close(); + }, + onError: onError, + ); + } + + sink.add(groupByStream); + return groupedController; + } + + @override + void onData(T data) { + final K key; + try { + key = grouper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + groups.putIfAbsent(key, () => _controllerBuilder(key)).add(data); + } + + @override + void onError(e, st) => sink.addError(e, st); + + @override + void onDone() { + _closeAll(); + sink.close(); + } + + @override + Future? onCancel() { + scheduleMicrotask(_closeAll); + + if (subscriptions?.isNotEmpty == true) { + final future = waitFuturesList([ + for (final s in subscriptions!.values) s.cancel(), + ]); + subscriptions?.clear(); + subscriptions = null; + return future; + } + return null; + } + + @override + FutureOr onListen() {} + + @override + void onPause() => subscriptions?.values.pauseAll(); + + @override + void onResume() => subscriptions?.values.resumeAll(); +} + +/// The GroupBy operator divides a [Stream] that emits items into +/// a [Stream] that emits [GroupedStream], +/// each one of which emits some subset of the items +/// from the original source [Stream]. +/// +/// [GroupedStream] acts like a regular [Stream], yet +/// adding a 'key' property, which receives its [Type] and value from +/// the [_grouper] Function. +/// +/// All items with the same key are emitted by the same [GroupedStream]. +class GroupByStreamTransformer + extends StreamTransformerBase> { + /// Method which converts incoming events into a new [GroupedStream] + final K Function(T event) grouper; + + /// A function that returns an [Stream] to determine how long each group should exist. + /// When the returned [Stream] emits its first data or done event, + /// the group will be closed and removed. + final Stream Function(GroupedStream grouped)? durationSelector; + + /// Constructs a [StreamTransformer] which groups events from the source + /// [Stream] and emits them as [GroupedStream]. + GroupByStreamTransformer(this.grouper, {this.durationSelector}); + + @override + Stream> bind(Stream stream) => forwardStream( + stream, () => _GroupByStreamSink(grouper, durationSelector)); +} + +/// The [Stream] used by [GroupByStreamTransformer], it contains events +/// that are grouped by a key value. +class GroupedStream extends StreamView { + /// The key is the category to which all events in this group belong to. + final K key; + + /// Constructs a [Stream] which only emits events that can be + /// categorized under [key]. + GroupedStream(this.key, Stream stream) : super(stream); + + @override + String toString() => 'GroupedStream{key: $key}'; +} + +/// Extends the Stream class with the ability to convert events into Streams +/// of events that are united by a key. +extension GroupByExtension on Stream { + /// The GroupBy operator divides a [Stream] that emits items into a [Stream] + /// that emits [GroupedStream], each one of which emits some subset of the + /// items from the original source [Stream]. + /// + /// [GroupedStream] acts like a regular [Stream], yet adding a 'key' property, + /// which receives its [Type] and value from the [grouper] Function. + /// + /// All items with the same key are emitted by the same [GroupedStream]. + /// + /// Optionally, `groupBy` takes a second argument [durationSelector]. + /// [durationSelector] is a function that returns an [Stream] to determine how long + /// each group should exist. When the returned [Stream] emits its first data or done event, + /// the group will be closed and removed. + Stream> groupBy( + K Function(T value) grouper, { + Stream Function(GroupedStream grouped)? durationSelector, + }) => + GroupByStreamTransformer(grouper, + durationSelector: durationSelector) + .bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/ignore_elements.dart b/sandbox/reactivex/lib/src/transformers/ignore_elements.dart new file mode 100644 index 0000000..a2f372b --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/ignore_elements.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +class _IgnoreElementsStreamSink implements EventSink { + final EventSink _outputSink; + + _IgnoreElementsStreamSink(this._outputSink); + + @override + void add(S data) {} + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Creates a [Stream] where all emitted items are ignored, only the +/// error / completed notifications are passed +/// +/// [ReactiveX doc](http://reactivex.io/documentation/operators/ignoreelements.html) +/// [Interactive marble diagram](https://rxmarbles.com/#ignoreElements) +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.fromIterable([1]), +/// ErrorStream(Exception()) +/// ]) +/// .transform(IgnoreElementsStreamTransformer()) +/// .listen(print, onError: print); // prints Exception +class IgnoreElementsStreamTransformer + extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which simply ignores all events from + /// the source [Stream], except for error or completed events. + IgnoreElementsStreamTransformer(); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _IgnoreElementsStreamSink(sink)); +} + +/// Extends the Stream class with the ability to skip, or ignore, data events. +extension IgnoreElementsExtension on Stream { + /// Creates a Stream where all emitted items are ignored, only the error / + /// completed notifications are passed + /// + /// [ReactiveX doc](http://reactivex.io/documentation/operators/ignoreelements.html) + /// [Interactive marble diagram](https://rxmarbles.com/#ignoreElements) + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// Stream.error(Exception()) + /// ]) + /// .ignoreElements() + /// .listen(print, onError: print); // prints Exception + Stream ignoreElements() => + IgnoreElementsStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/interval.dart b/sandbox/reactivex/lib/src/transformers/interval.dart new file mode 100644 index 0000000..4366760 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/interval.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:collection'; + +class _IntervalStreamSink implements EventSink { + final Duration _duration; + final EventSink _outputSink; + final _queue = Queue(); + var _inputClosed = false; + var _openIntervals = 0; + + bool get noOpenIntervals => _openIntervals == 0; + + _IntervalStreamSink(this._outputSink, this._duration); + + @override + void add(S data) { + _queue.add(data); + + if (noOpenIntervals) { + _addNext(); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _inputClosed = true; + + if (noOpenIntervals) { + _outputSink.close(); + } + } + + void _addNext() { + if (_queue.isNotEmpty) { + _addDelayed(_queue.removeFirst()).whenComplete(_addNext); + } + } + + Future _addDelayed(S data) { + _openIntervals++; + + return Future.delayed(_duration, () => data) + .then(_outputSink.add) + .whenComplete(() { + _openIntervals--; + + if (_inputClosed && _queue.isEmpty) { + _outputSink.close(); + } + }); + } +} + +/// Creates a Stream that emits each item in the Stream after a given +/// duration. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(IntervalStreamTransformer(Duration(seconds: 1))) +/// .listen((i) => print('$i sec')); // prints 1 sec, 2 sec, 3 sec +class IntervalStreamTransformer extends StreamTransformerBase { + /// The interval after which incoming events need to be emitted. + final Duration duration; + + /// Constructs a [StreamTransformer] which emits each item from the source [Stream], + /// after a given duration. + IntervalStreamTransformer(this.duration); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _IntervalStreamSink(sink, duration)); +} + +/// Extends the Stream class with the ability to emit each item after a given +/// duration. +extension IntervalExtension on Stream { + /// Creates a Stream that emits each item in the Stream after a given + /// duration. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .interval(Duration(seconds: 1)) + /// .listen((i) => print('$i sec'); // prints 1 sec, 2 sec, 3 sec + Stream interval(Duration duration) => + IntervalStreamTransformer(duration).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/map_not_null.dart b/sandbox/reactivex/lib/src/transformers/map_not_null.dart new file mode 100644 index 0000000..724d6ba --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/map_not_null.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +class _MapNotNullSink implements EventSink { + final R? Function(T) _transform; + final EventSink _outputSink; + + _MapNotNullSink(this._outputSink, this._transform); + + @override + void add(T event) { + final value = _transform(event); + if (value != null) { + _outputSink.add(value); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _outputSink.addError(error, stackTrace); + + @override + void close() => _outputSink.close(); +} + +/// Create a Stream containing only the non-`null` results +/// of applying the given [transform] function to each element of the Stream. +/// +/// ### Example +/// +/// Stream.fromIterable(['1', 'two', '3', 'four']) +/// .transform(MapNotNullStreamTransformer(int.tryParse)) +/// .listen(print); // prints 1, 3 +/// +/// // equivalent to: +/// +/// Stream.fromIterable(['1', 'two', '3', 'four']) +/// .map(int.tryParse) +/// .transform(WhereTypeStreamTransformer()) +/// .listen(print); // prints 1, 3 +class MapNotNullStreamTransformer + extends StreamTransformerBase { + /// A function that transforms each elements of the Stream. + final R? Function(T) transform; + + /// Constructs a [StreamTransformer] which emits non-`null` elements + /// of applying the given [transform] function to each element of the Stream. + const MapNotNullStreamTransformer(this.transform); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _MapNotNullSink(sink, transform)); +} + +/// Extends the Stream class with the ability to convert the source Stream +/// to a Stream containing only the non-`null` results +/// of applying the given [transform] function to each element of this Stream. +extension MapNotNullExtension on Stream { + /// Returns a Stream containing only the non-`null` results + /// of applying the given [transform] function to each element of this Stream. + /// + /// ### Example + /// + /// Stream.fromIterable(['1', 'two', '3', 'four']) + /// .mapNotNull(int.tryParse) + /// .listen(print); // prints 1, 3 + /// + /// // equivalent to: + /// + /// Stream.fromIterable(['1', 'two', '3', 'four']) + /// .map(int.tryParse) + /// .whereType() + /// .listen(print); // prints 1, 3 + Stream mapNotNull(R? Function(T) transform) => + MapNotNullStreamTransformer(transform).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/map_to.dart b/sandbox/reactivex/lib/src/transformers/map_to.dart new file mode 100644 index 0000000..d58d74a --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/map_to.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _MapToStreamSink implements EventSink { + final T _value; + final EventSink _outputSink; + + _MapToStreamSink(this._outputSink, this._value); + + @override + void add(S data) => _outputSink.add(_value); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Emits the given constant value on the output Stream every time the source +/// Stream emits a value. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .mapTo(true) +/// .listen(print); // prints true, true, true, true +class MapToStreamTransformer extends StreamTransformerBase { + /// A constant [value] which will always be returned when using this transformer. + final T value; + + /// Constructs a [StreamTransformer] which always maps every event from + /// the source [Stream] to a constant [value]. + MapToStreamTransformer(this.value); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _MapToStreamSink(sink, value)); +} + +/// Extends the Stream class with the ability to convert each item to the same +/// value. +extension MapToExtension on Stream { + /// Emits the given constant value on the output Stream every time the source + /// Stream emits a value. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .mapTo(true) + /// .listen(print); // prints true, true, true, true + Stream mapTo(T value) => MapToStreamTransformer(value).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/materialize.dart b/sandbox/reactivex/lib/src/transformers/materialize.dart new file mode 100644 index 0000000..e0f3d56 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/materialize.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _MaterializeStreamSink implements EventSink { + final EventSink> _outputSink; + + _MaterializeStreamSink(this._outputSink); + + @override + void add(S data) => _outputSink.add(StreamNotification.data(data)); + + @override + void addError(e, [st]) => _outputSink.add(StreamNotification.error(e, st)); + + @override + void close() { + _outputSink.add(StreamNotification.done()); + _outputSink.close(); + } +} + +/// Converts the onData, on Done, and onError events into [StreamNotification] +/// objects that are passed into the downstream onData listener. +/// +/// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, or +/// OnError), and the item or error that was emitted. In the case of onDone, +/// no data is emitted as part of the [StreamNotification]. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(MaterializeStreamTransformer()) +/// .listen(print); // Prints DataNotification{value: 1}, DoneNotification{} +class MaterializeStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which transforms the onData, on Done, + /// and onError events into [StreamNotification] objects. + MaterializeStreamTransformer(); + + @override + Stream> bind(Stream stream) => + Stream.eventTransformed( + stream, (sink) => _MaterializeStreamSink(sink)); +} + +/// Extends the Stream class with the ability to convert the onData, on Done, +/// and onError events into [StreamNotification]s that are passed into the +/// downstream onData listener. +extension MaterializeExtension on Stream { + /// Converts the onData, on Done, and onError events into [StreamNotification] + /// objects that are passed into the downstream onData listener. + /// + /// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, or + /// OnError), and the item or error that was emitted. In the case of onDone, + /// no data is emitted as part of the [StreamNotification]. + /// + /// Example: + /// Stream.fromIterable([1]) + /// .materialize() + /// .listen(print); // Prints DataNotification{value: 1}, DoneNotification{} + /// + /// Stream.error(Exception()) + /// .materialize() + /// .listen(print); // Prints ErrorNotification{error: Exception, stackTrace: }, DoneNotification{} + Stream> materialize() => + MaterializeStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/max.dart b/sandbox/reactivex/lib/src/transformers/max.dart new file mode 100644 index 0000000..ff66ae6 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/max.dart @@ -0,0 +1,25 @@ +import 'package:angel3_reactivex/src/utils/min_max.dart'; + +/// Extends the Stream class with the ability to transform into a Future +/// that completes with the largest item emitted by the Stream. +extension MaxExtension on Stream { + /// Converts a Stream into a Future that completes with the largest item + /// emitted by the Stream. + /// + /// This is similar to finding the max value in a list, but the values are + /// asynchronous. + /// + /// ### Example + /// + /// final max = await Stream.fromIterable([1, 2, 3]).max(); + /// + /// print(max); // prints 3 + /// + /// ### Example with custom [Comparator] + /// + /// final stream = Stream.fromIterable(['short', 'looooooong']); + /// final max = await stream.max((a, b) => a.length - b.length); + /// + /// print(max); // prints 'looooooong' + Future max([Comparator? comparator]) => minMax(this, false, comparator); +} diff --git a/sandbox/reactivex/lib/src/transformers/min.dart b/sandbox/reactivex/lib/src/transformers/min.dart new file mode 100644 index 0000000..41caec1 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/min.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/min_max.dart'; + +/// Extends the Stream class with the ability to transform into a Future +/// that completes with the smallest item emitted by the Stream. +extension MinExtension on Stream { + /// Converts a Stream into a Future that completes with the smallest item + /// emitted by the Stream. + /// + /// This is similar to finding the min value in a list, but the values are + /// asynchronous! + /// + /// ### Example + /// + /// final min = await Stream.fromIterable([1, 2, 3]).min(); + /// + /// print(min); // prints 1 + /// + /// ### Example with custom [Comparator] + /// + /// final stream = Stream.fromIterable(['short', 'looooooong']); + /// final min = await stream.min((a, b) => a.length - b.length); + /// + /// print(min); // prints 'short' + Future min([Comparator? comparator]) => minMax(this, true, comparator); +} diff --git a/sandbox/reactivex/lib/src/transformers/on_error_resume.dart b/sandbox/reactivex/lib/src/transformers/on_error_resume.dart new file mode 100644 index 0000000..ad574b0 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/on_error_resume.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _OnErrorResumeStreamSink extends ForwardingSink { + final Stream Function(Object error, StackTrace stackTrace) _recoveryFn; + final List> _recoverySubscriptions = []; + var closed = false; + + _OnErrorResumeStreamSink(this._recoveryFn); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) { + final Stream recoveryStream; + + try { + recoveryStream = _recoveryFn(e, st); + } catch (newError, newSt) { + sink.addError(newError, newSt); + return; + } + + final subscription = + recoveryStream.listen(sink.add, onError: sink.addError); + subscription.onDone(() { + _recoverySubscriptions.remove(subscription); + if (closed && _recoverySubscriptions.isEmpty) { + sink.close(); + } + }); + _recoverySubscriptions.add(subscription); + } + + @override + void onDone() { + closed = true; + if (_recoverySubscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() => _recoverySubscriptions.cancelAll(); + + @override + void onListen() {} + + @override + void onPause() => _recoverySubscriptions.pauseAll(); + + @override + void onResume() => _recoverySubscriptions.resumeAll(); +} + +/// Intercepts error events and switches to a recovery stream created by the +/// provided recoveryFn Function. +/// +/// The OnErrorResumeStreamTransformer intercepts an onError notification from +/// the source Stream. Instead of passing the error through to any +/// listeners, it replaces it with another Stream of items created by the +/// recoveryFn. +/// +/// The recoveryFn receives the emitted error and returns a Stream. You can +/// perform logic in the recoveryFn to return different Streams based on the +/// type of error that was emitted. +/// +/// ### Example +/// +/// Stream.error(Exception()) +/// .onErrorResume((dynamic e) => +/// Stream.value(e is StateError ? 1 : 0) +/// .listen(print); // prints 0 +class OnErrorResumeStreamTransformer extends StreamTransformerBase { + /// Method which returns a [Stream], based from the error. + final Stream Function(Object error, StackTrace stackTrace) recoveryFn; + + /// Constructs a [StreamTransformer] which intercepts error events and + /// switches to a recovery [Stream] created by the provided [recoveryFn] Function. + OnErrorResumeStreamTransformer(this.recoveryFn); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _OnErrorResumeStreamSink(recoveryFn), + ); +} + +/// Extends the Stream class with the ability to recover from errors in various +/// ways +extension OnErrorExtensions on Stream { + /// Intercepts error events and switches to the given recovery stream in + /// that case + /// + /// The onErrorResumeNext operator intercepts an onError notification from + /// the source Stream. Instead of passing the error through to any + /// listeners, it replaces it with another Stream of items. + /// + /// If you need to perform logic based on the type of error that was emitted, + /// please consider using [onErrorResume]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorResumeNext(Stream.fromIterable([1, 2, 3])) + /// .listen(print); // prints 1, 2, 3 + Stream onErrorResumeNext(Stream recoveryStream) => + OnErrorResumeStreamTransformer((_, __) => recoveryStream).bind(this); + + /// Intercepts error events and switches to a recovery stream created by the + /// provided [recoveryFn]. + /// + /// The onErrorResume operator intercepts an onError notification from + /// the source Stream. Instead of passing the error through to any + /// listeners, it replaces it with another Stream of items created by the + /// [recoveryFn]. + /// + /// The [recoveryFn] receives the emitted error and returns a Stream. You can + /// perform logic in the [recoveryFn] to return different Streams based on the + /// type of error that was emitted. + /// + /// If you do not need to perform logic based on the type of error that was + /// emitted, please consider using [onErrorResumeNext] or [onErrorReturn]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorResume((e, st) => + /// Stream.fromIterable([e is StateError ? 1 : 0])) + /// .listen(print); // prints 0 + Stream onErrorResume( + Stream Function(Object error, StackTrace stackTrace) recoveryFn) => + OnErrorResumeStreamTransformer(recoveryFn).bind(this); + + /// Instructs a Stream to emit a particular item when it encounters an + /// error, and then terminate normally + /// + /// The onErrorReturn operator intercepts an onError notification from + /// the source Stream. Instead of passing it through to any observers, it + /// replaces it with a given item, and then terminates normally. + /// + /// If you need to perform logic based on the type of error that was emitted, + /// please consider using [onErrorReturnWith]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorReturn(1) + /// .listen(print); // prints 1 + Stream onErrorReturn(T returnValue) => + OnErrorResumeStreamTransformer((_, __) => Stream.value(returnValue)) + .bind(this); + + /// Instructs a Stream to emit a particular item created by the + /// [returnFn] when it encounters an error, and then terminate normally. + /// + /// The onErrorReturnWith operator intercepts an onError notification from + /// the source Stream. Instead of passing it through to any observers, it + /// replaces it with a given item, and then terminates normally. + /// + /// The [returnFn] receives the emitted error and returns a value. You can + /// perform logic in the [returnFn] to return different value based on the + /// type of error that was emitted. + /// + /// If you do not need to perform logic based on the type of error that was + /// emitted, please consider using [onErrorReturn]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorReturnWith((e, st) => e is Exception ? 1 : 0) + /// .listen(print); // prints 1 + Stream onErrorReturnWith( + T Function(Object error, StackTrace stackTrace) returnFn) => + OnErrorResumeStreamTransformer( + (e, st) => Stream.value(returnFn(e, st))).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/scan.dart b/sandbox/reactivex/lib/src/transformers/scan.dart new file mode 100644 index 0000000..a2d4e71 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/scan.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +class _ScanStreamSink implements EventSink { + final T Function(T accumulated, S value, int index) _accumulator; + final EventSink _outputSink; + T _acc; + var _index = 0; + + _ScanStreamSink(this._outputSink, this._accumulator, this._acc); + + @override + void add(S data) => + _outputSink.add(_acc = _accumulator(_acc, data, _index++)); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Applies an accumulator function over an stream sequence and returns +/// each intermediate result. The seed value is used as the initial +/// accumulator value. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(ScanStreamTransformer((acc, curr, i) => acc + curr, 0)) +/// .listen(print); // prints 1, 3, 6 +class ScanStreamTransformer extends StreamTransformerBase { + /// Method which accumulates incoming event into a single, accumulated object + final T Function(T accumulated, S value, int index) accumulator; + + /// The initial value for the accumulated value in the [accumulator] + final T seed; + + /// Constructs a [ScanStreamTransformer] which applies an accumulator Function + /// over the source [Stream] and returns each intermediate result. + /// The seed value is used as the initial accumulator value. + ScanStreamTransformer(this.accumulator, this.seed); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _ScanStreamSink(sink, accumulator, seed)); +} + +/// Extends +extension ScanExtension on Stream { + /// Applies an accumulator function over a Stream sequence and returns each + /// intermediate result. The seed value is used as the initial + /// accumulator value. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .scan((acc, curr, i) => acc + curr, 0) + /// .listen(print); // prints 1, 3, 6 + Stream scan( + S Function(S accumulated, T value, int index) accumulator, S seed) => + ScanStreamTransformer(accumulator, seed).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/skip_last.dart b/sandbox/reactivex/lib/src/transformers/skip_last.dart new file mode 100644 index 0000000..1bbdfe6 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/skip_last.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SkipLastStreamSink extends ForwardingSink { + _SkipLastStreamSink(this.count); + + final int count; + final List queue = []; + + @override + void onData(T data) { + queue.add(data); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + final limit = queue.length - count; + if (limit > 0) { + queue.sublist(0, limit).forEach(sink.add); + } + sink.close(); + } + + @override + FutureOr onCancel() { + queue.clear(); + } + + @override + void onListen() {} + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Skip the last [count] items emitted by the source [Stream] +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// .transform(SkipLastStreamTransformer(3)) +/// .listen(print); // prints 1, 2 +class SkipLastStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which skip the last [count] items + /// emitted by the source [Stream] + SkipLastStreamTransformer(this.count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + } + + /// The [count] of final items to skip. + final int count; + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SkipLastStreamSink(count)); +} + +/// Extends the Stream class with the ability to skip the last [count] items +/// emitted by the source [Stream] +extension SkipLastExtension on Stream { + /// Starts emitting every items except last [count] items. + /// This causes items to be delayed. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// .skipLast(3) + /// .listen(print); // prints 1, 2 + Stream skipLast(int count) => + SkipLastStreamTransformer(count).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/skip_until.dart b/sandbox/reactivex/lib/src/transformers/skip_until.dart new file mode 100644 index 0000000..accf206 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/skip_until.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SkipUntilStreamSink extends ForwardingSink { + final Stream _otherStream; + StreamSubscription? _otherSubscription; + var _canAdd = false; + + _SkipUntilStreamSink(this._otherStream); + + @override + void onData(S data) { + if (_canAdd) { + sink.add(data); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _otherSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _otherSubscription?.cancel(); + + @override + void onListen() => _otherSubscription = _otherStream + .take(1) + .listen(null, onError: sink.addError, onDone: () => _canAdd = true); + + @override + void onPause() => _otherSubscription?.pause(); + + @override + void onResume() => _otherSubscription?.resume(); +} + +/// Starts emitting events only after the given stream emits an event. +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.value(1), +/// TimerStream(2, Duration(minutes: 2)) +/// ]) +/// .transform(SkipUntilStreamTransformer(TimerStream(1, Duration(minutes: 1)))) +/// .listen(print); // prints 2; +class SkipUntilStreamTransformer extends StreamTransformerBase { + /// The [Stream] which is required to emit first, before this [Stream] starts emitting + final Stream otherStream; + + /// Constructs a [StreamTransformer] which starts emitting events + /// only after [otherStream] emits an event. + SkipUntilStreamTransformer(this.otherStream); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SkipUntilStreamSink(otherStream)); +} + +/// Extends the Stream class with the ability to skip events until another +/// Stream emits an item. +extension SkipUntilExtension on Stream { + /// Starts emitting items only after the given stream emits an item. + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// TimerStream(2, Duration(minutes: 2)) + /// ]) + /// .skipUntil(TimerStream(true, Duration(minutes: 1))) + /// .listen(print); // prints 2; + Stream skipUntil(Stream otherStream) => + SkipUntilStreamTransformer(otherStream).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/start_with.dart b/sandbox/reactivex/lib/src/transformers/start_with.dart new file mode 100644 index 0000000..60e966c --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/start_with.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithStreamSink extends ForwardingSink { + final S _startValue; + + _StartWithStreamSink(this._startValue); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + sink.add(_startValue); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends a value to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(StartWithStreamTransformer(1)) +/// .listen(print); // prints 1, 2 +class StartWithStreamTransformer extends StreamTransformerBase { + /// The starting event of this [Stream] + final S startValue; + + /// Constructs a [StreamTransformer] which prepends the source [Stream] + /// with [startValue]. + StartWithStreamTransformer(this.startValue); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithStreamSink(startValue)); +} + +/// Extends the [Stream] class with the ability to emit the given value as the +/// first item. +extension StartWithExtension on Stream { + /// Prepends a value to the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).startWith(1).listen(print); // prints 1, 2 + Stream startWith(T startValue) => + StartWithStreamTransformer(startValue).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/start_with_error.dart b/sandbox/reactivex/lib/src/transformers/start_with_error.dart new file mode 100644 index 0000000..7a74a08 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/start_with_error.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithErrorStreamSink extends ForwardingSink { + final Object _e; + final StackTrace? _st; + + _StartWithErrorStreamSink(this._e, this._st); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + sink.addError(_e, _st); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends an error to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(StartWithErrorStreamTransformer('error')) +/// .listen(null, onError: (e) => print(e)); // prints 'error' +class StartWithErrorStreamTransformer extends StreamTransformerBase { + /// The starting error of this [Stream] + final Object error; + + /// The starting stackTrace of this [Stream] + final StackTrace? stackTrace; + + /// Constructs a [StreamTransformer] which starts with the provided [error] + /// and then outputs all events from the source [Stream]. + StartWithErrorStreamTransformer(this.error, [this.stackTrace]); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithErrorStreamSink(error, stackTrace)); +} diff --git a/sandbox/reactivex/lib/src/transformers/start_with_many.dart b/sandbox/reactivex/lib/src/transformers/start_with_many.dart new file mode 100644 index 0000000..7c8e401 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/start_with_many.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithManyStreamSink extends ForwardingSink { + final Iterable _startValues; + + _StartWithManyStreamSink(this._startValues); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + _startValues.forEach(sink.add); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends a sequence of values to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([3]) +/// .transform(StartWithManyStreamTransformer([1, 2])) +/// .listen(print); // prints 1, 2, 3 +class StartWithManyStreamTransformer extends StreamTransformerBase { + /// The starting events of this [Stream] + final Iterable startValues; + + /// Constructs a [StreamTransformer] which prepends the source [Stream] + /// with [startValues]. + StartWithManyStreamTransformer(this.startValues); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithManyStreamSink(startValues)); +} + +/// Extends the [Stream] class with the ability to emit the given values as the +/// first items. +extension StartWithManyExtension on Stream { + /// Prepends a sequence of values to the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([3]).startWithMany([1, 2]) + /// .listen(print); // prints 1, 2, 3 + Stream startWithMany(List startValues) => + StartWithManyStreamTransformer(startValues).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/switch_if_empty.dart b/sandbox/reactivex/lib/src/transformers/switch_if_empty.dart new file mode 100644 index 0000000..aa5f779 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/switch_if_empty.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SwitchIfEmptyStreamSink extends ForwardingSink { + final Stream _fallbackStream; + + var _isEmpty = true; + StreamSubscription? _fallbackSubscription; + + _SwitchIfEmptyStreamSink(this._fallbackStream); + + @override + void onData(S data) { + _isEmpty = false; + sink.add(data); + } + + @override + void onError(Object error, StackTrace st) { + sink.addError(error, st); + } + + @override + void onDone() { + if (_isEmpty) { + _fallbackSubscription = _fallbackStream.listen( + sink.add, + onError: sink.addError, + onDone: sink.close, + ); + } else { + sink.close(); + } + } + + @override + FutureOr onCancel() => _fallbackSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _fallbackSubscription?.pause(); + + @override + void onResume() => _fallbackSubscription?.resume(); +} + +/// When the original stream emits no items, this operator subscribes to +/// the given fallback stream and emits items from that stream instead. +/// +/// This can be particularly useful when consuming data from multiple sources. +/// For example, when using the Repository Pattern. Assuming you have some +/// data you need to load, you might want to start with the fastest access +/// point and keep falling back to the slowest point. For example, first query +/// an in-memory database, then a database on the file system, then a network +/// call if the data isn't on the local machine. +/// +/// This can be achieved quite simply with switchIfEmpty! +/// +/// ### Example +/// +/// // Let's pretend we have some Data sources that complete without emitting +/// // any items if they don't contain the data we're looking for +/// Stream memory; +/// Stream disk; +/// Stream network; +/// +/// // Start with memory, fallback to disk, then fallback to network. +/// // Simple as that! +/// Stream getThatData = +/// memory.switchIfEmpty(disk).switchIfEmpty(network); +class SwitchIfEmptyStreamTransformer extends StreamTransformerBase { + /// The [Stream] which will be used as fallback, if the source [Stream] is empty. + final Stream fallbackStream; + + /// Constructs a [StreamTransformer] which, when the source [Stream] emits + /// no events, switches over to [fallbackStream]. + SwitchIfEmptyStreamTransformer(this.fallbackStream); + + @override + Stream bind(Stream stream) { + return forwardStream( + stream, () => _SwitchIfEmptyStreamSink(fallbackStream)); + } +} + +/// Extend the Stream class with the ability to return an alternative Stream +/// if the initial Stream completes with no items. +extension SwitchIfEmptyExtension on Stream { + /// When the original Stream emits no items, this operator subscribes to the + /// given fallback stream and emits items from that Stream instead. + /// + /// This can be particularly useful when consuming data from multiple sources. + /// For example, when using the Repository Pattern. Assuming you have some + /// data you need to load, you might want to start with the fastest access + /// point and keep falling back to the slowest point. For example, first query + /// an in-memory database, then a database on the file system, then a network + /// call if the data isn't on the local machine. + /// + /// This can be achieved quite simply with switchIfEmpty! + /// + /// ### Example + /// + /// // Let's pretend we have some Data sources that complete without + /// // emitting any items if they don't contain the data we're looking for + /// Stream memory; + /// Stream disk; + /// Stream network; + /// + /// // Start with memory, fallback to disk, then fallback to network. + /// // Simple as that! + /// Stream getThatData = + /// memory.switchIfEmpty(disk).switchIfEmpty(network); + Stream switchIfEmpty(Stream fallbackStream) => + SwitchIfEmptyStreamTransformer(fallbackStream).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/switch_map.dart b/sandbox/reactivex/lib/src/transformers/switch_map.dart new file mode 100644 index 0000000..63b63ae --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/switch_map.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SwitchMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + StreamSubscription? _mapperSubscription; + bool _inputClosed = false; + bool _isCancelled = false; + + _SwitchMapStreamSink(this._mapper); + + @override + void onData(S data) { + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + final mapperSubscription = _mapperSubscription; + + if (mapperSubscription == null) { + listenToInner(mappedStream); + return; + } + + _mapperSubscription = null; + pauseSubscription(); + mapperSubscription.cancel().onError((e, s) { + if (!_isCancelled) { + sink.addError(e, s); + } + }).whenComplete(() => resumeAndListenToInner(mappedStream)); + } + + void resumeAndListenToInner(Stream mappedStream) { + if (_isCancelled) { + return; + } + + resumeSubscription(); + listenToInner(mappedStream); + } + + void listenToInner(Stream mappedStream) { + assert(_mapperSubscription == null); + + _mapperSubscription = mappedStream.listen( + sink.add, + onError: sink.addError, + onDone: () { + _mapperSubscription = null; + + if (_inputClosed) { + sink.close(); + } + }, + ); + + // https://github.com/dart-lang/stream_transform/blob/9743578b0119de6a8badd30bb16ef15d79bd3b15/lib/src/switch.dart#L71-L74 + // If a pause happens during an _mapperSubscription.cancel, + // we still listen to the next stream when the cancel is done. + // Then we immediately pause it again here. + if (sink.isPaused) { + _mapperSubscription?.pause(); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + _mapperSubscription ?? sink.close(); + } + + @override + FutureOr onCancel() { + _isCancelled = true; + + return _mapperSubscription?.cancel(); + } + + @override + void onListen() {} + + @override + void onPause() => _mapperSubscription?.pause(); + + @override + void onResume() => _mapperSubscription?.resume(); +} + +/// Converts each emitted item into a new Stream using the given mapper +/// function. The newly created Stream will be be listened to and begin +/// emitting items, and any previously created Stream will stop emitting. +/// +/// The switchMap operator is similar to the flatMap and concatMap +/// methods, but it only emits items from the most recently created Stream. +/// +/// This can be useful when you only want the very latest state from +/// asynchronous APIs, for example. +/// +/// ### Example +/// +/// Stream.fromIterable([4, 3, 2, 1]) +/// .transform(SwitchMapStreamTransformer((i) => +/// Stream.fromFuture( +/// Future.delayed(Duration(minutes: i), () => i)) +/// .listen(print); // prints 1 +class SwitchMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Constructs a [StreamTransformer] which maps each event from the source [Stream] + /// using [mapper]. + /// + /// The mapped [Stream] will be be listened to and begin + /// emitting items, and any previously created mapper [Stream]s will stop emitting. + SwitchMapStreamTransformer(this.mapper); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SwitchMapStreamSink(mapper)); +} + +/// Extends the Stream with the ability to convert one stream into a new Stream +/// whenever the source emits an item. Every time a new Stream is created, the +/// previous Stream is discarded. +extension SwitchMapExtension on Stream { + /// Converts each emitted item into a Stream using the given mapper function. + /// The newly created Stream will be be listened to and begin emitting items, + /// and any previously created Stream will stop emitting. + /// + /// The switchMap operator is similar to the flatMap and concatMap methods, + /// but it only emits items from the most recently created Stream. + /// + /// This can be useful when you only want the very latest state from + /// asynchronous APIs, for example. + /// + /// ### Example + /// + /// RangeStream(4, 1) + /// .switchMap((i) => + /// TimerStream(i, Duration(minutes: i))) + /// .listen(print); // prints 1 + Stream switchMap(Stream Function(T value) mapper) => + SwitchMapStreamTransformer(mapper).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/take_last.dart b/sandbox/reactivex/lib/src/transformers/take_last.dart new file mode 100644 index 0000000..56a11a7 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/take_last.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TakeLastStreamSink extends ForwardingSink { + _TakeLastStreamSink(this.count); + + final int count; + final Queue queue = DoubleLinkedQueue(); + + @override + void onData(T data) { + if (count > 0) { + queue.addLast(data); + if (queue.length > count) { + queue.removeFirst(); + } + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + if (queue.isNotEmpty) { + queue.toList(growable: false).forEach(sink.add); + } + sink.close(); + } + + @override + FutureOr onCancel() { + queue.clear(); + } + + @override + void onListen() {} + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Emits only the final [count] values emitted by the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// .transform(TakeLastStreamTransformer(3)) +/// .listen(print); // prints 3, 4, 5 +class TakeLastStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which emits only the final [count] + /// events from the source [Stream]. + TakeLastStreamTransformer(this.count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + } + + /// The [count] of final items emitted when the stream completes. + final int count; + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _TakeLastStreamSink(count)); +} + +/// Extends the [Stream] class with the ability receive only the final [count] +/// events from the source [Stream]. +extension TakeLastExtension on Stream { + /// Emits only the final [count] values emitted by the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// .takeLast(3) + /// .listen(print); // prints 3, 4, 5 + Stream takeLast(int count) => + TakeLastStreamTransformer(count).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/take_until.dart b/sandbox/reactivex/lib/src/transformers/take_until.dart new file mode 100644 index 0000000..54fe0d6 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/take_until.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TakeUntilStreamSink extends ForwardingSink { + final Stream _otherStream; + StreamSubscription? _otherSubscription; + + _TakeUntilStreamSink(this._otherStream); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _otherSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _otherSubscription?.cancel(); + + @override + void onListen() => _otherSubscription = _otherStream + .take(1) + .listen(null, onError: sink.addError, onDone: sink.close); + + @override + void onPause() => _otherSubscription?.pause(); + + @override + void onResume() => _otherSubscription?.resume(); +} + +/// Returns the values from the source stream sequence until the other +/// stream sequence produces a value. +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(minutes: 1)) +/// ]) +/// .transform(TakeUntilStreamTransformer( +/// TimerStream(3, Duration(seconds: 10)))) +/// .listen(print); // prints 1 +class TakeUntilStreamTransformer extends StreamTransformerBase { + /// The [Stream] which closes this [Stream] as soon as it emits an event. + final Stream otherStream; + + /// Constructs a [StreamTransformer] which emits events from the source [Stream], + /// until [otherStream] fires. + TakeUntilStreamTransformer(this.otherStream); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _TakeUntilStreamSink(otherStream)); +} + +/// Extends the Stream class with the ability receive events from the source +/// Stream until another Stream produces a value. +extension TakeUntilExtension on Stream { + /// Returns the values from the source Stream sequence until the other Stream + /// sequence produces a value. + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// TimerStream(2, Duration(minutes: 1)) + /// ]) + /// .takeUntil(TimerStream(3, Duration(seconds: 10))) + /// .listen(print); // prints 1 + Stream takeUntil(Stream otherStream) => + TakeUntilStreamTransformer(otherStream).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/take_while_inclusive.dart b/sandbox/reactivex/lib/src/transformers/take_while_inclusive.dart new file mode 100644 index 0000000..8471db1 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/take_while_inclusive.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +class _TakeWhileInclusiveStreamSink implements EventSink { + final bool Function(S) _test; + final EventSink _outputSink; + + _TakeWhileInclusiveStreamSink(this._outputSink, this._test); + + @override + void add(S data) { + bool satisfies; + + try { + satisfies = _test(data); + } catch (e, s) { + _outputSink.addError(e, s); + // The test didn't say true. Didn't say false either, but we stop anyway. + _outputSink.close(); + return; + } + + if (satisfies) { + _outputSink.add(data); + } else { + _outputSink.add(data); + _outputSink.close(); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Emits values emitted by the source Stream so long as each value +/// satisfies the given test. When the test is not satisfied by a value, it +/// will emit this value as a final event and then complete. +/// +/// ### Example +/// +/// Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) +/// .transform(TakeWhileInclusiveStreamTransformer((i) => i < 4)) +/// .listen(print); // prints 2, 3, 4 +class TakeWhileInclusiveStreamTransformer + extends StreamTransformerBase { + /// Method used to test incoming events + final bool Function(S) test; + + /// Constructs a [StreamTransformer] which forwards data events while [test] + /// is successful, and includes last event that caused [test] to return false. + TakeWhileInclusiveStreamTransformer(this.test); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _TakeWhileInclusiveStreamSink(sink, test)); +} + +/// Extends the Stream class with the ability to take events while they pass +/// the condition given and include last event that doesn't pass the condition. +extension TakeWhileInclusiveExtension on Stream { + /// Emits values emitted by the source Stream so long as each value + /// satisfies the given test. When the test is not satisfied by a value, it + /// will emit this value as a final event and then complete. + /// + /// ### Example + /// + /// Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) + /// .takeWhileInclusive((i) => i < 4) + /// .listen(print); // prints 2, 3, 4 + Stream takeWhileInclusive(bool Function(T) test) => + TakeWhileInclusiveStreamTransformer(test).bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/time_interval.dart b/sandbox/reactivex/lib/src/transformers/time_interval.dart new file mode 100644 index 0000000..b7e3c71 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/time_interval.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TimeIntervalStreamSink extends ForwardingSink> { + final _stopwatch = Stopwatch(); + + @override + void onData(S data) { + _stopwatch.stop(); + sink.add( + TimeInterval( + data, + Duration( + microseconds: _stopwatch.elapsedMicroseconds, + ), + ), + ); + _stopwatch + ..reset() + ..start(); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() => _stopwatch.start(); + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Records the time interval between consecutive values in an stream +/// sequence. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(IntervalStreamTransformer(Duration(seconds: 1))) +/// .transform(TimeIntervalStreamTransformer()) +/// .listen(print); // prints TimeInterval{interval: 0:00:01, value: 1} +class TimeIntervalStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which emits events from the + /// source [Stream] as snapshots in the form of [TimeInterval]. + TimeIntervalStreamTransformer(); + + @override + Stream> bind(Stream stream) => + forwardStream(stream, () => _TimeIntervalStreamSink()); +} + +/// A class that represents a snapshot of the current value emitted by a +/// [Stream], at a specified interval. +class TimeInterval { + /// The interval at which this snapshot was taken + final Duration interval; + + /// The value at the moment of [interval] + final T value; + + /// Constructs a snapshot of a [Stream], containing the [Stream]'s event + /// at the specified [interval] as [value]. + TimeInterval(this.value, this.interval); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TimeInterval && + interval == other.interval && + value == other.value; + } + + @override + int get hashCode { + return interval.hashCode ^ value.hashCode; + } + + @override + String toString() { + return 'TimeInterval{interval: $interval, value: $value}'; + } +} + +/// Extends the Stream class with the ability to record the time interval +/// between consecutive values in an stream +extension TimeIntervalExtension on Stream { + /// Records the time interval between consecutive values in a Stream sequence. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .interval(Duration(seconds: 1)) + /// .timeInterval() + /// .listen(print); // prints TimeInterval{interval: 0:00:01, value: 1} + Stream> timeInterval() => + TimeIntervalStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/timestamp.dart b/sandbox/reactivex/lib/src/transformers/timestamp.dart new file mode 100644 index 0000000..0564402 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/timestamp.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +class _TimestampStreamSink implements EventSink { + final EventSink> _outputSink; + + _TimestampStreamSink(this._outputSink); + + @override + void add(S data) { + _outputSink.add(Timestamped(DateTime.now(), data)); + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Wraps each item emitted by the source Stream in a [Timestamped] object +/// that includes the emitted item and the time when the item was emitted. +/// +/// Example +/// +/// Stream.fromIterable([1]) +/// .transform(TimestampStreamTransformer()) +/// .listen((i) => print(i)); // prints 'TimeStamp{timestamp: XXX, value: 1}'; +class TimestampStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which emits events from the + /// source [Stream] as snapshots in the form of [Timestamped]. + TimestampStreamTransformer(); + + @override + Stream> bind(Stream stream) => + Stream.eventTransformed(stream, (sink) => _TimestampStreamSink(sink)); +} + +/// A class that represents a snapshot of the current value emitted by a +/// [Stream], at a specified timestamp. +class Timestamped { + /// The value at the moment of the [timestamp] + final T value; + + /// The time at which this snapshot was taken + final DateTime timestamp; + + /// Constructs a snapshot of a [Stream], containing the [Stream]'s event + /// at the specified [timestamp] as [value]. + Timestamped(this.timestamp, this.value); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is Timestamped && + timestamp == other.timestamp && + value == other.value; + } + + @override + int get hashCode { + return timestamp.hashCode ^ value.hashCode; + } + + @override + String toString() { + return 'TimeStamp{timestamp: $timestamp, value: $value}'; + } +} + +/// Extends the Stream class with the ability to wrap each item emitted by the +/// source Stream in a [Timestamped] object that includes the emitted item and +/// the time when the item was emitted. +extension TimeStampExtension on Stream { + /// Wraps each item emitted by the source Stream in a [Timestamped] object + /// that includes the emitted item and the time when the item was emitted. + /// + /// Example + /// + /// Stream.fromIterable([1]) + /// .timestamp() + /// .listen((i) => print(i)); // prints 'TimeStamp{timestamp: XXX, value: 1}'; + Stream> timestamp() => + TimestampStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/where_not_null.dart b/sandbox/reactivex/lib/src/transformers/where_not_null.dart new file mode 100644 index 0000000..5fdcb4e --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/where_not_null.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +class _WhereNotNullStreamSink implements EventSink { + final EventSink _outputSink; + + _WhereNotNullStreamSink(this._outputSink); + + @override + void add(T? event) { + if (event != null) { + _outputSink.add(event); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _outputSink.addError(error, stackTrace); + + @override + void close() => _outputSink.close(); +} + +/// Create a Stream which emits all the non-`null` elements of the Stream, +/// in their original emission order. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, null, 4, null]) +/// .transform(WhereNotNullStreamTransformer()) +/// .listen(print); // prints 1, 2, 3, 4 +/// +/// // equivalent to: +/// +/// Stream.fromIterable([1, 2, 3, null, 4, null]) +/// .transform(WhereTypeStreamTransformer()) +/// .listen(print); // prints 1, 2, 3, 4 +class WhereNotNullStreamTransformer + extends StreamTransformerBase { + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _WhereNotNullStreamSink(sink)); +} + +/// Extends the Stream class with the ability to convert the source Stream +/// to a Stream which emits all the non-`null` elements +/// of this Stream, in their original emission order. +extension WhereNotNullExtension on Stream { + /// Returns a Stream which emits all the non-`null` elements + /// of this Stream, in their original emission order. + /// + /// For a `Stream`, this method is equivalent to `.whereType()`. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, null, 4, null]) + /// .whereNotNull() + /// .listen(print); // prints 1, 2, 3, 4 + /// + /// // equivalent to: + /// + /// Stream.fromIterable([1, 2, 3, null, 4, null]) + /// .whereType() + /// .listen(print); // prints 1, 2, 3, 4 + Stream whereNotNull() => WhereNotNullStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/where_type.dart b/sandbox/reactivex/lib/src/transformers/where_type.dart new file mode 100644 index 0000000..22a76a6 --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/where_type.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +class _WhereTypeStreamSink implements EventSink { + final EventSink _outputSink; + + _WhereTypeStreamSink(this._outputSink); + + @override + void add(S data) { + if (data is T) { + _outputSink.add(data); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// This transformer is a shorthand for [Stream.where] followed by [Stream.cast]. +/// +/// Events that do not match [T] are filtered out, the resulting +/// [Stream] will be of Type [T]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 'two', 3, 'four']) +/// .whereType() +/// .listen(print); // prints 1, 3 +/// +/// // as opposed to: +/// +/// Stream.fromIterable([1, 'two', 3, 'four']) +/// .where((event) => event is int) +/// .cast() +/// .listen(print); // prints 1, 3 +/// +class WhereTypeStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which combines [Stream.where] followed by [Stream.cast]. + WhereTypeStreamTransformer(); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _WhereTypeStreamSink(sink)); +} + +/// Extends the Stream class with the ability to filter down events to only +/// those of a specific type. +extension WhereTypeExtension on Stream { + /// This transformer is a shorthand for [Stream.where] followed by + /// [Stream.cast]. + /// + /// Events that do not match [T] are filtered out, the resulting [Stream] will + /// be of Type [T]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 'two', 3, 'four']) + /// .whereType() + /// .listen(print); // prints 1, 3 + /// + /// #### as opposed to: + /// + /// Stream.fromIterable([1, 'two', 3, 'four']) + /// .where((event) => event is int) + /// .cast() + /// .listen(print); // prints 1, 3 + Stream whereType() => WhereTypeStreamTransformer().bind(this); +} diff --git a/sandbox/reactivex/lib/src/transformers/with_latest_from.dart b/sandbox/reactivex/lib/src/transformers/with_latest_from.dart new file mode 100644 index 0000000..8e3013c --- /dev/null +++ b/sandbox/reactivex/lib/src/transformers/with_latest_from.dart @@ -0,0 +1,738 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _WithLatestFromStreamSink extends ForwardingSink { + final Iterable> _latestFromStreams; + final R Function(S t, List values) _combiner; + + bool _hasValues = false; + List? _latestValues; + late List> _subscriptions; + + _WithLatestFromStreamSink(this._latestFromStreams, this._combiner); + + @override + void onData(S data) { + if (_hasValues && _latestValues != null) { + final R combinedValue; + try { + combinedValue = _combiner(data, List.unmodifiable(_latestValues!)); + } catch (e, s) { + sink.addError(e, s); + return; + } + sink.add(combinedValue); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + Future? onCancel() { + _latestValues = null; + return _subscriptions.cancelAll(); + } + + @override + void onListen() { + var count = 0; + + StreamSubscription mapper(int index, Stream stream) { + var hasValue = false; + + return stream.listen( + (value) { + if (!hasValue) { + hasValue = true; + if (++count == _subscriptions.length) { + _hasValues = true; + } + } + _latestValues![index] = value; + }, + onError: sink.addError, + ); + } + + _subscriptions = + _latestFromStreams.mapIndexed(mapper).toList(growable: false); + if (_subscriptions.isEmpty) { + _hasValues = true; + } + _latestValues = List.filled(_subscriptions.length, null); + } + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// A StreamTransformer that emits when the source stream emits, combining +/// the latest values from the two streams using the provided function. +/// +/// If the latestFromStream has not emitted any values, this stream will not +/// emit either. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2]).transform( +/// WithLatestFromStreamTransformer( +/// Stream.fromIterable([2, 3]), (a, b) => a + b) +/// .listen(print); // prints 4 (due to the async nature of streams) +class WithLatestFromStreamTransformer + extends StreamTransformerBase { + /// A collection of [Stream]s of which the latest values will be combined. + final Iterable> latestFromStreams; + + /// The combiner Function + final R Function(S t, List values) combiner; + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStreams] using the provided function [fn]. + WithLatestFromStreamTransformer(this.latestFromStreams, this.combiner); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStreams] using a [List]. + static WithLatestFromStreamTransformer> withList( + Iterable> latestFromStreams, + ) { + return WithLatestFromStreamTransformer>( + latestFromStreams, + (s, values) => [s, ...values], + ); + } + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStream] using the provided function [fn]. + static WithLatestFromStreamTransformer with1( + Stream latestFromStream, + R Function(T t, S s) fn, + ) => + WithLatestFromStreamTransformer( + [latestFromStream], + (s, values) => fn(s, values[0]), + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with2( + Stream latestFromStream1, + Stream latestFromStream2, + R Function(T t, A a, B b) fn, + ) => + WithLatestFromStreamTransformer( + [latestFromStream1, latestFromStream2], + (s, values) => fn(s, values[0] as A, values[1] as B), + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with3( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + R Function(T t, A a, B b, C c) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with4( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + R Function(T t, A a, B b, C c, D d) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with5( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + R Function(T t, A a, B b, C c, D d, E e) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with6( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + R Function(T t, A a, B b, C c, D d, E e, F f) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with7( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + R Function(T t, A a, B b, C c, D d, E e, F f, G g) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with8( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with9( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + Stream latestFromStream9, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h, I i) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + latestFromStream9, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _WithLatestFromStreamSink(latestFromStreams, combiner), + ); +} + +/// Extends the Stream class with the ability to merge the source Stream with +/// the last emitted item from another Stream. +extension WithLatestFromExtensions on Stream { + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the two streams using the provided function. + /// + /// If the latestFromStream has not emitted any values, this stream will not + /// emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]).withLatestFrom( + /// Stream.fromIterable([2, 3]), (a, b) => a + b) + /// .listen(print); // prints 4 (due to the async nature of streams) + Stream withLatestFrom( + Stream latestFromStream, R Function(T t, S s) fn) => + WithLatestFromStreamTransformer.with1(latestFromStream, fn) + .bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the streams into a list. This is helpful when you need + /// to combine a dynamic number of Streams. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// Stream.fromIterable([1, 2]).withLatestFromList( + /// [ + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// ], + /// ).listen(print); // print [2, 2, 3, 4, 5, 6] (due to the async nature of streams) + /// + Stream> withLatestFromList(Iterable> latestFromStreams) => + WithLatestFromStreamTransformer.withList(latestFromStreams).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the three streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom2( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// (int a, int b, int c) => a + b + c, + /// ) + /// .listen(print); // prints 7 (due to the async nature of streams) + Stream withLatestFrom2( + Stream latestFromStream1, + Stream latestFromStream2, + R Function(T t, A a, B b) fn, + ) => + WithLatestFromStreamTransformer.with2( + latestFromStream1, + latestFromStream2, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the four streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom3( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// (int a, int b, int c, int d) => a + b + c + d, + /// ) + /// .listen(print); // prints 11 (due to the async nature of streams) + Stream withLatestFrom3( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + R Function(T t, A a, B b, C c) fn, + ) => + WithLatestFromStreamTransformer.with3( + latestFromStream1, + latestFromStream2, + latestFromStream3, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the five streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom4( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// (int a, int b, int c, int d, int e) => a + b + c + d + e, + /// ) + /// .listen(print); // prints 16 (due to the async nature of streams) + Stream withLatestFrom4( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + R Function(T t, A a, B b, C c, D d) fn, + ) => + WithLatestFromStreamTransformer.with4( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the six streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom5( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// (int a, int b, int c, int d, int e, int f) => a + b + c + d + e + f, + /// ) + /// .listen(print); // prints 22 (due to the async nature of streams) + Stream withLatestFrom5( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + R Function(T t, A a, B b, C c, D d, E e) fn, + ) => + WithLatestFromStreamTransformer.with5( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the seven streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom6( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// (int a, int b, int c, int d, int e, int f, int g) => + /// a + b + c + d + e + f + g, + /// ) + /// .listen(print); // prints 29 (due to the async nature of streams) + Stream withLatestFrom6( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + R Function(T t, A a, B b, C c, D d, E e, F f) fn, + ) => + WithLatestFromStreamTransformer.with6( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the eight streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom7( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// (int a, int b, int c, int d, int e, int f, int g, int h) => + /// a + b + c + d + e + f + g + h, + /// ) + /// .listen(print); // prints 37 (due to the async nature of streams) + Stream withLatestFrom7( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + R Function(T t, A a, B b, C c, D d, E e, F f, G g) fn, + ) => + WithLatestFromStreamTransformer.with7( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the nine streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom8( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// Stream.fromIterable([9, 10]), + /// (int a, int b, int c, int d, int e, int f, int g, int h, int i) => + /// a + b + c + d + e + f + g + h + i, + /// ) + /// .listen(print); // prints 46 (due to the async nature of streams) + Stream withLatestFrom8( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h) fn, + ) => + WithLatestFromStreamTransformer.with8( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the ten streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom9( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// Stream.fromIterable([9, 10]), + /// Stream.fromIterable([10, 11]), + /// (int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) => + /// a + b + c + d + e + f + g + h + i + j, + /// ) + /// .listen(print); // prints 46 (due to the async nature of streams) + Stream withLatestFrom9( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + Stream latestFromStream9, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h, I i) fn, + ) => + WithLatestFromStreamTransformer.with9( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + latestFromStream9, + fn, + ).bind(this); +} diff --git a/sandbox/reactivex/lib/src/utils/collection_extensions.dart b/sandbox/reactivex/lib/src/utils/collection_extensions.dart new file mode 100644 index 0000000..a58f9ee --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/collection_extensions.dart @@ -0,0 +1,65 @@ +import 'dart:collection'; +import 'dart:math'; + +/// @internal +/// @nodoc +/// Provides extension methods on [List]. +extension ListExtensions on List { + /// @internal + /// Returns a list of values built from the elements of this list + /// and the other list with the same index + /// using the provided transform function applied to each pair of elements. + /// The returned list has length of the shortest list. + List zipWith( + List other, + R Function(T, S) transform, { + bool growable = true, + }) => + List.generate( + min(length, other.length), + (index) => transform(this[index], other[index]), + growable: growable, + ); +} + +/// @internal +/// Provides extension methods on [Iterable]. +extension IterableExtensions on Iterable { + /// @internal + /// The non-`null` results of calling [transform] on the elements of [this]. + /// + /// Returns a lazy iterable which calls [transform] + /// on the elements of this iterable in iteration order, + /// then emits only the non-`null` values. + /// + /// If [transform] throws, the iteration is terminated. + Iterable mapNotNull(R? Function(T) transform) sync* { + for (final e in this) { + final v = transform(e); + if (v != null) { + yield v; + } + } + } + + /// @internal + /// Maps each element and its index to a new value. + Iterable mapIndexed(R Function(int index, T element) transform) sync* { + var index = 0; + for (final e in this) { + yield transform(index++, e); + } + } +} + +/// @internal +/// Provides [removeFirstElements] extension method on [Queue]. +extension RemoveFirstElementsQueueExtension on Queue { + /// @internal + /// Removes the first [count] elements of this queue. + void removeFirstElements(int count) { + for (var i = 0; i < count; i++) { + removeFirst(); + } + } +} diff --git a/sandbox/reactivex/lib/src/utils/composite_subscription.dart b/sandbox/reactivex/lib/src/utils/composite_subscription.dart new file mode 100644 index 0000000..7745b8d --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/composite_subscription.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Acts as a container for multiple subscriptions that can be canceled at once +/// e.g. view subscriptions in Flutter that need to be canceled on view disposal +/// +/// Can be cleared or disposed. When disposed, cannot be used again. +/// ### Example +/// // init your subscriptions +/// composite.add(stream1.listen(listener1)) +/// ..add(stream2.listen(listener1)) +/// ..add(stream3.listen(listener1)); +/// +/// // clear them all at once +/// composite.clear(); +class CompositeSubscription implements StreamSubscription { + bool _isDisposed = false; + + final List> _subscriptionsList = []; + + /// Checks if this composite is disposed. If it is, the composite can't be used again + /// and will throw an error if you try to add more subscriptions to it. + bool get isDisposed => _isDisposed; + + /// Returns the total amount of currently added [StreamSubscription]s + int get length => _subscriptionsList.length; + + /// Checks if there currently are no [StreamSubscription]s added + bool get isEmpty => _subscriptionsList.isEmpty; + + /// Checks if there currently are [StreamSubscription]s added + bool get isNotEmpty => _subscriptionsList.isNotEmpty; + + /// Whether all managed [StreamSubscription]s are currently paused. + bool get allPaused => + _subscriptionsList.isNotEmpty && + _subscriptionsList.every((s) => s.isPaused); + + /// Adds new subscription to this composite. + /// + /// Throws an exception if this composite was disposed + StreamSubscription add(StreamSubscription subscription) { + if (isDisposed) { + throw StateError( + 'This $runtimeType was disposed, consider checking `isDisposed` or try to use new instance instead'); + } + _subscriptionsList.add(subscription); + return subscription; + } + + /// Remove the subscription from this composite and cancel it if it has been removed. + Future? remove( + StreamSubscription subscription, { + bool shouldCancel = true, + }) => + _subscriptionsList.remove(subscription) && shouldCancel + ? subscription.cancel() + : null; + + /// Cancels all subscriptions added to this composite. Clears subscriptions collection. + /// + /// This composite can be reused after calling this method. + Future? clear() { + final cancelAllDone = _subscriptionsList.cancelAll(); + _subscriptionsList.clear(); + return cancelAllDone; + } + + /// Cancels all subscriptions added to this composite. Disposes this. + /// + /// This composite can't be reused after calling this method. + Future? dispose() { + final clearDone = clear(); + _isDisposed = true; + return clearDone; + } + + /// Pauses all subscriptions added to this composite. + void pauseAll([Future? resumeSignal]) => + _subscriptionsList.pauseAll(resumeSignal); + + /// Resumes all subscriptions added to this composite. + void resumeAll() => _subscriptionsList.resumeAll(); + + // implements StreamSubscription + + @override + Future cancel() => dispose() ?? Future.value(null); + + @override + bool get isPaused => allPaused; + + @override + void pause([Future? resumeSignal]) => pauseAll(resumeSignal); + + @override + void resume() => resumeAll(); + + @override + Never asFuture([E? futureValue]) => _unsupportedError(); + + @override + Never onData(void Function(Never data)? handleData) => _unsupportedError(); + + @override + Never onDone(void Function()? handleDone) => _unsupportedError(); + + @override + Never onError(Function? handleError) => _unsupportedError(); + + Never _unsupportedError() => throw UnsupportedError( + 'Cannot change handlers of CompositeSubscription.'); +} + +/// Extends the [StreamSubscription] class with the ability to be added to [CompositeSubscription] container. +extension AddToCompositeSubscriptionExtension on StreamSubscription { + /// Adds this subscription to composite container for subscriptions. + void addTo(CompositeSubscription compositeSubscription) => + compositeSubscription.add(this); +} diff --git a/sandbox/reactivex/lib/src/utils/empty.dart b/sandbox/reactivex/lib/src/utils/empty.dart new file mode 100644 index 0000000..04a7766 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/empty.dart @@ -0,0 +1,18 @@ +class _Empty { + const _Empty(); + + @override + String toString() => '<>'; +} + +/// @internal +/// Sentinel object used to represent a missing value (distinct from `null`). +const Object? EMPTY = _Empty(); // ignore: constant_identifier_names + +/// @internal +/// Returns `null` if [o] is [EMPTY], otherwise returns itself. +T? unbox(Object? o) => identical(o, EMPTY) ? null : o as T; + +/// @internal +/// Returns `true` if [o] is not [EMPTY]. +bool isNotEmpty(Object? o) => !identical(o, EMPTY); diff --git a/sandbox/reactivex/lib/src/utils/error_and_stacktrace.dart b/sandbox/reactivex/lib/src/utils/error_and_stacktrace.dart new file mode 100644 index 0000000..33a68c9 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/error_and_stacktrace.dart @@ -0,0 +1,28 @@ +/// An Object which acts as a tuple containing both an error and the +/// corresponding stack trace. +class ErrorAndStackTrace { + /// A reference to the wrapped error object. + final Object error; + + /// A reference to the wrapped [StackTrace] + final StackTrace? stackTrace; + + /// Constructs an object containing both an [error] and the + /// corresponding [stackTrace]. + ErrorAndStackTrace(this.error, this.stackTrace); + + @override + String toString() => + 'ErrorAndStackTrace{error: $error, stackTrace: $stackTrace}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ErrorAndStackTrace && + runtimeType == other.runtimeType && + error == other.error && + stackTrace == other.stackTrace; + + @override + int get hashCode => error.hashCode ^ stackTrace.hashCode; +} diff --git a/sandbox/reactivex/lib/src/utils/forwarding_sink.dart b/sandbox/reactivex/lib/src/utils/forwarding_sink.dart new file mode 100644 index 0000000..65adbd0 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/forwarding_sink.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// A enhanced [EventSink] that allows to check if the sink is paused. +abstract class EnhancedEventSink implements EventSink { + /// Whether the subscription would need to buffer events. + bool get isPaused; +} + +/// A [Sink] that supports event hooks. +/// +/// This makes it suitable for certain rx transformers that need to +/// take action after onListen, onPause, onResume or onCancel. +/// +/// The [ForwardingSink] has been designed to handle asynchronous events from +/// [Stream]s. See, for example, [Stream.eventTransformed] which uses +/// `EventSink`s to transform events. +abstract class ForwardingSink { + EnhancedEventSink? _sink; + StreamSubscription? _subscription; + + /// The output sink. + /// @nonVirtual + /// @internal + EnhancedEventSink get sink => + _sink ?? (throw StateError('Must call setSink(sink) before accessing!')); + + /// Set the output sink. + /// @nonVirtual + /// @internal + void setSink(EnhancedEventSink sink) => _sink = sink; + + /// Set the upstream subscription + /// @nonVirtual + /// @internal + void setSubscription(StreamSubscription? subscription) => + _subscription = subscription; + + /// -------------------------------------------------------------------------- + + /// Pause the upstream subscription. + /// @nonVirtual + void pauseSubscription() => _subscription?.pause(); + + /// Resume the upstream subscription. + /// @nonVirtual + void resumeSubscription() => _subscription?.resume(); + + /// -------------------------------------------------------------------------- + + /// Handle data event + void onData(T data); + + /// Handle error event + void onError(Object error, StackTrace st); + + /// Handle close event + void onDone(); + + /// Fires when a listener subscribes on the underlying [Stream]. + /// Returns a [Future] to delay listening to source [Stream]. + FutureOr onListen(); + + /// Fires when a subscriber pauses. + void onPause(); + + /// Fires when a subscriber resumes after a pause. + void onResume(); + + /// Fires when a subscriber cancels. + FutureOr onCancel(); +} + +/// @internal +/// @nodoc +extension EventSinkExtension on EventSink { + /// @internal + /// @nodoc + void addErrorAndStackTrace(ErrorAndStackTrace errorAndSt) => + addError(errorAndSt.error, errorAndSt.stackTrace); +} diff --git a/sandbox/reactivex/lib/src/utils/forwarding_stream.dart b/sandbox/reactivex/lib/src/utils/forwarding_stream.dart new file mode 100644 index 0000000..9d414f4 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/forwarding_stream.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; + +/// @private +/// Helper method which forwards the events from an incoming [Stream] +/// to a new [StreamController]. +/// It captures events such as onListen, onPause, onResume and onCancel, +/// which can be used in pair with a [ForwardingSink] +Stream forwardStream( + Stream stream, + ForwardingSink Function() sinkFactory, [ + bool listenOnlyOnce = false, +]) { + return stream.isBroadcast + ? listenOnlyOnce + ? _forward(stream, sinkFactory) + : _forwardMulti(stream, sinkFactory) + : _forward(stream, sinkFactory); +} + +Stream _forwardMulti( + Stream stream, ForwardingSink Function() sinkFactory) { + return Stream.multi((controller) { + final sink = sinkFactory(); + sink.setSink(_MultiControllerSink(controller)); + + StreamSubscription? subscription; + var cancelled = false; + + void listenToUpstream([void _]) { + if (cancelled) { + return; + } + subscription = stream.listen( + sink.onData, + onError: sink.onError, + onDone: sink.onDone, + ); + sink.setSubscription(subscription); + } + + final futureOrVoid = sink.onListen(); + if (futureOrVoid is Future) { + futureOrVoid.then(listenToUpstream).onError((e, s) { + if (!cancelled && !controller.isClosed) { + controller.addError(e, s); + controller.close(); + } + }); + } else { + listenToUpstream(); + } + + controller.onCancel = () { + cancelled = true; + + final future = subscription?.cancel(); + subscription = null; + sink.setSubscription(null); + + return waitTwoFutures(future, sink.onCancel()); + }; + }, isBroadcast: true); +} + +Stream _forward( + Stream stream, + ForwardingSink Function() sinkFactory, +) { + final controller = stream.isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + StreamSubscription? subscription; + var cancelled = false; + late final sink = sinkFactory(); + + controller.onListen = () { + void listenToUpstream([void _]) { + if (cancelled) { + return; + } + subscription = stream.listen( + sink.onData, + onError: sink.onError, + onDone: sink.onDone, + ); + sink.setSubscription(subscription); + + if (!stream.isBroadcast) { + controller.onPause = () { + subscription!.pause(); + sink.onPause(); + }; + controller.onResume = () { + subscription!.resume(); + sink.onResume(); + }; + } + } + + sink.setSink(_EnhancedEventSink(controller)); + final futureOrVoid = sink.onListen(); + if (futureOrVoid is Future) { + futureOrVoid.then(listenToUpstream).onError((e, s) { + if (!cancelled && !controller.isClosed) { + controller.addError(e, s); + controller.close(); + } + }); + } else { + listenToUpstream(); + } + }; + controller.onCancel = () { + cancelled = true; + + final future = subscription?.cancel(); + subscription = null; + sink.setSubscription(null); + + return waitTwoFutures(future, sink.onCancel()); + }; + return controller.stream; +} + +class _MultiControllerSink implements EventSink, EnhancedEventSink { + final MultiStreamController controller; + + _MultiControllerSink(this.controller); + + @override + void add(T event) => controller.addSync(event); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + controller.addErrorSync(error, stackTrace); + + @override + void close() => controller.closeSync(); + + @override + bool get isPaused => controller.isPaused; +} + +class _EnhancedEventSink implements EnhancedEventSink { + final StreamController _controller; + + _EnhancedEventSink(this._controller); + + @override + void add(T event) => _controller.add(event); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _controller.addError(error, stackTrace); + + @override + void close() => _controller.close(); + + @override + bool get isPaused => _controller.isPaused; +} diff --git a/sandbox/reactivex/lib/src/utils/future.dart b/sandbox/reactivex/lib/src/utils/future.dart new file mode 100644 index 0000000..77e241f --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/future.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// @internal +/// An optimized version of [Future.wait]. +FutureOr waitTwoFutures(Future? f1, FutureOr f2) => f1 == null + ? f2 + : f2 is Future + ? Future.wait([f1, f2]).then(_ignore) + : f1; + +/// @internal +/// An optimized version of [Future.wait]. +Future? waitFuturesList(List> futures) { + switch (futures.length) { + case 0: + return null; + case 1: + return futures[0]; + default: + return Future.wait(futures).then(_ignore); + } +} + +/// Helper function to ignore future callback +void _ignore(Object? _) {} diff --git a/sandbox/reactivex/lib/src/utils/min_max.dart b/sandbox/reactivex/lib/src/utils/min_max.dart new file mode 100644 index 0000000..729d44c --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/min_max.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +/// @private +/// Helper method which find max value or min value in a stream +/// +/// When the stream is done, the returned future is completed with +/// the largest value or smallest value at that time. +/// +/// If the stream is empty, the returned future is completed with +/// an error. +/// If the stream emits an error, or the call to [comparator] throws, +/// the returned future is completed with that error, +/// and processing is stopped. +Future minMax(Stream stream, bool findMin, Comparator? comparator) { + var completer = Completer(); + var seenFirst = false; + + late StreamSubscription subscription; + late T accumulator; + late Comparator comparatorNotNull; + + Future cancelAndCompleteError(Object e, StackTrace st) async { + await subscription.cancel(); + + completer.completeError(e, st); + } + + void onData(T element) async { + if (seenFirst) { + try { + accumulator = findMin + ? (comparatorNotNull(element, accumulator) < 0 + ? element + : accumulator) + : (comparatorNotNull(element, accumulator) > 0 + ? element + : accumulator); + } catch (e, st) { + await cancelAndCompleteError(e, st); + } + return; + } + + accumulator = element; + seenFirst = true; + try { + comparatorNotNull = comparator ?? + () { + if (element is Comparable) { + return Comparable.compare as Comparator; + } else { + throw StateError( + 'Please provide a comparator for type $T, because it is not comparable'); + } + }(); + } catch (e, st) { + await cancelAndCompleteError(e, st); + } + } + + void onDone() { + if (seenFirst) { + completer.complete(accumulator); + } else { + completer.completeError(StateError('No element')); + } + } + + subscription = stream.listen( + onData, + onError: completer.completeError, + onDone: onDone, + cancelOnError: true, + ); + return completer.future; +} diff --git a/sandbox/reactivex/lib/src/utils/notification.dart b/sandbox/reactivex/lib/src/utils/notification.dart new file mode 100644 index 0000000..fec6172 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/notification.dart @@ -0,0 +1,169 @@ +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// The type of event used in [StreamNotification] +enum NotificationKind { + /// Specifies a data event + data, + + /// Specifies a done event + done, + + /// Specifies an error event + error +} + +/// A class that encapsulates the [NotificationKind] of event, value of the event in case of +/// onData, or the Error in the case of onError. + +/// A container object that wraps the [NotificationKind] of event (OnData, OnDone, OnError), +/// and the item or error that was emitted. In the case of onDone, no data is +/// emitted as part of the [StreamNotification]. +abstract class StreamNotification { + /// References the [NotificationKind] of this [StreamNotification] event. + final NotificationKind kind; + + const StreamNotification._(this.kind); + + /// Constructs a [StreamNotification] with [NotificationKind.data] and wraps a [value] + factory StreamNotification.data(T value) => DataNotification(value); + + /// Constructs a [StreamNotification] with [NotificationKind.done]. + const factory StreamNotification.done() = DoneNotification; + + /// Constructs a [StreamNotification] with [NotificationKind.error] and wraps an [error] and [stackTrace] + factory StreamNotification.error(Object error, [StackTrace? stackTrace]) => + ErrorNotification._internal(error, stackTrace); +} + +/// Provides extension methods on [StreamNotification]. +extension StreamNotificationExtensions on StreamNotification { + /// A test to determine if this [StreamNotification] wraps a data event. + bool get isData => kind == NotificationKind.data; + + /// A test to determine if this [StreamNotification] wraps a done event. + bool get isDone => kind == NotificationKind.done; + + /// A test to determine if this [StreamNotification] wraps an error event. + bool get isError => kind == NotificationKind.error; + + /// Returns data if [kind] is [NotificationKind.data], + /// otherwise throws a [TypeError] error. + /// See also [dataValueOrNull]. + T get requireDataValue => (this as DataNotification).value; + + /// Returns data if [kind] is [NotificationKind.data], + /// otherwise returns null. + T? get dataValueOrNull { + final self = this; + return self is DataNotification ? self.value : null; + } + + /// Returns error and stack trace if [kind] is [NotificationKind.error], + /// otherwise throws a [TypeError] error. + ErrorAndStackTrace get requireErrorAndStackTrace => + (this as ErrorNotification).errorAndStackTrace; + + /// Returns error and stack trace if [kind] is [NotificationKind.error], + /// otherwise returns null. + ErrorAndStackTrace? get errorAndStackTraceOrNull { + final self = this; + return self is ErrorNotification ? self.errorAndStackTrace : null; + } + + /// Invokes the appropriate function on the [StreamNotification] based on the [kind]. + @pragma('vm:prefer-inline') + @pragma('dart2js:prefer-inline') + R when({ + required R Function(T value) data, + required R Function() done, + required R Function(ErrorAndStackTrace) error, + }) { + final self = this; + if (self is DataNotification) { + return data(self.value); + } + + if (self is DoneNotification) { + return done(); + } + + if (self is ErrorNotification) { + return error(self.errorAndStackTrace); + } + + throw StateError('Unknown notification $self'); + } +} + +/// A notification representing a data event from a [Stream]. +class DataNotification extends StreamNotification { + /// The value of the data event. + final T value; + + /// Constructs a [DataNotification] with the provided [value]. + const DataNotification(this.value) : super._(NotificationKind.data); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataNotification && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'DataNotification{value: $value}'; +} + +/// A notification representing a done event from a [Stream]. +class DoneNotification extends StreamNotification { + /// Constructs a [DoneNotification]. + const DoneNotification() : super._(NotificationKind.done); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DoneNotification && runtimeType == other.runtimeType; + + @override + int get hashCode => 0; + + @override + String toString() => 'DoneNotification{}'; +} + +/// A notification representing an error event from a [Stream]. +class ErrorNotification extends StreamNotification { + /// The wrapped error and stack trace, if applicable + final ErrorAndStackTrace errorAndStackTrace; + + /// The error of the error event. + Object get error => errorAndStackTrace.error; + + /// The stack trace of the error event, if available. + StackTrace? get stackTrace => errorAndStackTrace.stackTrace; + + /// Constructs an [ErrorNotification] with the provided [errorAndStackTrace]. + const ErrorNotification(this.errorAndStackTrace) + : super._(NotificationKind.error); + + /// Constructs an [ErrorNotification] with the provided [error] and [stackTrace]. + factory ErrorNotification._internal(Object error, StackTrace? stackTrace) => + ErrorNotification(ErrorAndStackTrace(error, stackTrace)); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ErrorNotification && + runtimeType == other.runtimeType && + errorAndStackTrace == other.errorAndStackTrace; + + @override + int get hashCode => errorAndStackTrace.hashCode; + + @override + String toString() => + 'ErrorNotification{error: $error, stackTrace: $stackTrace}'; +} diff --git a/sandbox/reactivex/lib/src/utils/subscription.dart b/sandbox/reactivex/lib/src/utils/subscription.dart new file mode 100644 index 0000000..d3500d9 --- /dev/null +++ b/sandbox/reactivex/lib/src/utils/subscription.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/future.dart'; + +/// @internal +/// Extensions for [Iterable] of [StreamSubscription]s. +extension StreamSubscriptionsIterableExtensions + on Iterable> { + /// @internal + /// Pause all subscriptions. + void pauseAll([Future? resumeSignal]) { + for (final s in this) { + s.pause(resumeSignal); + } + } + + /// @internal + /// Resume all subscriptions. + void resumeAll() { + for (final s in this) { + s.resume(); + } + } +} + +/// @internal +/// Extensions for [Iterable] of [StreamSubscription]s. +extension StreamSubscriptionsIterableExtension + on Iterable> { + /// @internal + /// Cancel all subscriptions. + Future? cancelAll() => + waitFuturesList([for (final s in this) s.cancel()]); +} diff --git a/sandbox/reactivex/lib/streams.dart b/sandbox/reactivex/lib/streams.dart new file mode 100644 index 0000000..79f57b3 --- /dev/null +++ b/sandbox/reactivex/lib/streams.dart @@ -0,0 +1,23 @@ +library rx_streams; + +export 'src/streams/combine_latest.dart'; +export 'src/streams/concat.dart'; +export 'src/streams/concat_eager.dart'; +export 'src/streams/connectable_stream.dart'; +export 'src/streams/defer.dart'; +export 'src/streams/fork_join.dart'; +export 'src/streams/from_callable.dart'; +export 'src/streams/merge.dart'; +export 'src/streams/never.dart'; +export 'src/streams/race.dart'; +export 'src/streams/range.dart'; +export 'src/streams/repeat.dart'; +export 'src/streams/replay_stream.dart'; +export 'src/streams/retry.dart'; +export 'src/streams/retry_when.dart'; +export 'src/streams/sequence_equal.dart'; +export 'src/streams/switch_latest.dart'; +export 'src/streams/timer.dart'; +export 'src/streams/using.dart'; +export 'src/streams/value_stream.dart'; +export 'src/streams/zip.dart'; diff --git a/sandbox/reactivex/lib/subjects.dart b/sandbox/reactivex/lib/subjects.dart new file mode 100644 index 0000000..77bc472 --- /dev/null +++ b/sandbox/reactivex/lib/subjects.dart @@ -0,0 +1,6 @@ +library rx_subjects; + +export 'src/subjects/behavior_subject.dart'; +export 'src/subjects/publish_subject.dart'; +export 'src/subjects/replay_subject.dart'; +export 'src/subjects/subject.dart'; diff --git a/sandbox/reactivex/lib/transformers.dart b/sandbox/reactivex/lib/transformers.dart new file mode 100644 index 0000000..e6ff971 --- /dev/null +++ b/sandbox/reactivex/lib/transformers.dart @@ -0,0 +1,42 @@ +library rx_transformers; + +export 'src/transformers/backpressure/buffer.dart'; +export 'src/transformers/backpressure/debounce.dart'; +export 'src/transformers/backpressure/pairwise.dart'; +export 'src/transformers/backpressure/sample.dart'; +export 'src/transformers/backpressure/throttle.dart'; +export 'src/transformers/backpressure/window.dart'; +export 'src/transformers/default_if_empty.dart'; +export 'src/transformers/delay.dart'; +export 'src/transformers/delay_when.dart'; +export 'src/transformers/dematerialize.dart'; +export 'src/transformers/distinct_unique.dart'; +export 'src/transformers/do.dart'; +export 'src/transformers/end_with.dart'; +export 'src/transformers/end_with_many.dart'; +export 'src/transformers/exhaust_map.dart'; +export 'src/transformers/flat_map.dart'; +export 'src/transformers/group_by.dart'; +export 'src/transformers/ignore_elements.dart'; +export 'src/transformers/interval.dart'; +export 'src/transformers/map_not_null.dart'; +export 'src/transformers/map_to.dart'; +export 'src/transformers/materialize.dart'; +export 'src/transformers/max.dart'; +export 'src/transformers/min.dart'; +export 'src/transformers/on_error_resume.dart'; +export 'src/transformers/scan.dart'; +export 'src/transformers/skip_last.dart'; +export 'src/transformers/skip_until.dart'; +export 'src/transformers/start_with.dart'; +export 'src/transformers/start_with_many.dart'; +export 'src/transformers/switch_if_empty.dart'; +export 'src/transformers/switch_map.dart'; +export 'src/transformers/take_last.dart'; +export 'src/transformers/take_until.dart'; +export 'src/transformers/take_while_inclusive.dart'; +export 'src/transformers/time_interval.dart'; +export 'src/transformers/timestamp.dart'; +export 'src/transformers/where_not_null.dart'; +export 'src/transformers/where_type.dart'; +export 'src/transformers/with_latest_from.dart'; diff --git a/sandbox/reactivex/lib/utils.dart b/sandbox/reactivex/lib/utils.dart new file mode 100644 index 0000000..5892516 --- /dev/null +++ b/sandbox/reactivex/lib/utils.dart @@ -0,0 +1,5 @@ +library rx_utils; + +export 'src/utils/composite_subscription.dart'; +export 'src/utils/error_and_stacktrace.dart'; +export 'src/utils/notification.dart'; diff --git a/sandbox/reactivex/pubspec.yaml b/sandbox/reactivex/pubspec.yaml new file mode 100644 index 0000000..e54c0bf --- /dev/null +++ b/sandbox/reactivex/pubspec.yaml @@ -0,0 +1,25 @@ +name: angel3_reactivex +version: 0.28.0 +description: > + angel3_reactivex is an implementation of the popular ReactiveX api for asynchronous + programming, leveraging the native Dart Streams api. +repository: https://github.com/ReactiveX/angel3_reactivex + +topics: + - angel3_reactivex + - reactive-programming + - streams + - observables + - rx + +environment: + sdk: '>=2.12.0 <4.0.0' + +dev_dependencies: + lints: ^1.0.1 + stack_trace: ^1.10.0 + test: ^1.17.12 + +screenshots: + - description: The angel3_reactivex package logo. + path: screenshots/logo.png diff --git a/sandbox/reactivex/screenshots/logo.png b/sandbox/reactivex/screenshots/logo.png new file mode 100644 index 0000000..1ba0f82 Binary files /dev/null and b/sandbox/reactivex/screenshots/logo.png differ diff --git a/sandbox/reactivex/test/rxdart_test.dart b/sandbox/reactivex/test/rxdart_test.dart new file mode 100644 index 0000000..ef41ddc --- /dev/null +++ b/sandbox/reactivex/test/rxdart_test.dart @@ -0,0 +1,187 @@ +library test.rx; + +import 'streams/combine_latest_test.dart' as combine_latest_test; +import 'streams/concat_eager_test.dart' as concat_eager_test; +import 'streams/concat_test.dart' as concat_test; +import 'streams/defer_test.dart' as defer_test; +import 'streams/fork_join_test.dart' as fork_join_test; +import 'streams/from_callable_test.dart' as from_callable_test; +import 'streams/merge_test.dart' as merge_test; +import 'streams/never_test.dart' as never_test; +import 'streams/publish_connectable_stream_test.dart' + as publish_connectable_stream_test; +import 'streams/race_test.dart' as race_test; +import 'streams/range_test.dart' as range_test; +import 'streams/repeat_test.dart' as repeat_test; +import 'streams/replay_connectable_stream_test.dart' + as replay_connectable_stream_test; +import 'streams/retry_test.dart' as retry_test; +import 'streams/retry_when_test.dart' as retry_when_test; +import 'streams/sequence_equals_test.dart' as sequence_equals_test; +import 'streams/switch_latest_test.dart' as switch_latest_test; +import 'streams/timer_test.dart' as timer_test; +import 'streams/using_test.dart' as using_test; +import 'streams/value_connectable_stream_test.dart' + as value_connectable_stream_test; +import 'streams/zip_test.dart' as zip_test; +import 'subject/behavior_subject_test.dart' as behaviour_subject_test; +import 'subject/publish_subject_test.dart' as publish_subject_test; +import 'subject/replay_subject_test.dart' as replay_subject_test; +import 'transformers/backpressure/buffer_count_test.dart' as buffer_count_test; +import 'transformers/backpressure/buffer_test.dart' as buffer_test; +import 'transformers/backpressure/buffer_test_test.dart' as buffer_test_test; +import 'transformers/backpressure/buffer_time_test.dart' as buffer_time_test; +import 'transformers/backpressure/debounce_test.dart' as debounce_test; +import 'transformers/backpressure/debounce_time_test.dart' + as debounce_time_test; +import 'transformers/backpressure/pairwise_test.dart' as pairwise_test; +import 'transformers/backpressure/sample_test.dart' as sample_test; +import 'transformers/backpressure/sample_time_test.dart' as sample_time_test; +import 'transformers/backpressure/throttle_test.dart' as throttle_test; +import 'transformers/backpressure/throttle_time_test.dart' + as throttle_time_test; +import 'transformers/backpressure/window_count_test.dart' as window_count_test; +import 'transformers/backpressure/window_test.dart' as window_test; +import 'transformers/backpressure/window_test_test.dart' as window_test_test; +import 'transformers/backpressure/window_time_test.dart' as window_time_test; +import 'transformers/concat_with_test.dart' as concat_with_test; +import 'transformers/default_if_empty_test.dart' as default_if_empty_test; +import 'transformers/delay_test.dart' as delay_test; +import 'transformers/delay_when_test.dart' as delay_when_test; +import 'transformers/dematerialize_test.dart' as dematerialize_test; +import 'transformers/distinct_test.dart' as distinct_test; +import 'transformers/distinct_unique_test.dart' as distinct_unique_test; +import 'transformers/do_test.dart' as do_test; +import 'transformers/end_with_many_test.dart' as end_with_many_test; +import 'transformers/end_with_test.dart' as end_with_test; +import 'transformers/exhaust_map_test.dart' as exhaust_map_test; +import 'transformers/flat_map_iterable_test.dart' as flat_map_iterable_test; +import 'transformers/flat_map_test.dart' as flat_map_test; +import 'transformers/group_by_test.dart' as group_by_test; +import 'transformers/ignore_elements_test.dart' as ignore_elements_test; +import 'transformers/interval_test.dart' as interval_test; +import 'transformers/join_test.dart' as join_test; +import 'transformers/map_not_null_test.dart' as map_not_null_test; +import 'transformers/map_to_test.dart' as map_to_test; +import 'transformers/materialize_test.dart' as materialize_test; +import 'transformers/merge_with_test.dart' as merge_with_test; +import 'transformers/on_error_return_test.dart' as on_error_resume_test; +import 'transformers/on_error_return_test.dart' as on_error_return_test; +import 'transformers/on_error_return_with_test.dart' + as on_error_return_with_test; +import 'transformers/scan_test.dart' as scan_test; +import 'transformers/skip_last_test.dart' as skip_last_test; +import 'transformers/skip_until_test.dart' as skip_until_test; +import 'transformers/start_with_many_test.dart' as start_with_many_test; +import 'transformers/start_with_test.dart' as start_with_test; +import 'transformers/switch_if_empty_test.dart' as switch_if_empty_test; +import 'transformers/switch_map_test.dart' as switch_map_test; +import 'transformers/take_last_test.dart' as take_last_test; +import 'transformers/take_until_test.dart' as take_until_test; +import 'transformers/take_while_inclusive_test.dart' + as take_while_inclusive_test; +import 'transformers/time_interval_test.dart' as time_interval_test; +import 'transformers/timeout_test.dart' as timeout_test; +import 'transformers/timestamp_test.dart' as timestamp_test; +import 'transformers/where_not_null_test.dart' as where_not_null_test; +import 'transformers/where_type_test.dart' as where_type_test; +import 'transformers/with_latest_from_test.dart' as with_latest_from_test; +import 'transformers/zip_with_test.dart' as zip_with_test; +import 'utils/composite_subscription_test.dart' as composite_subscription_test; +import 'utils/notification_test.dart' as notification_test; + +void main() { + // Streams + combine_latest_test.main(); + concat_eager_test.main(); + concat_test.main(); + defer_test.main(); + fork_join_test.main(); + from_callable_test.main(); + merge_test.main(); + never_test.main(); + range_test.main(); + race_test.main(); + repeat_test.main(); + retry_test.main(); + retry_when_test.main(); + sequence_equals_test.main(); + switch_latest_test.main(); + using_test.main(); + zip_test.main(); + + // StreamTransformers + concat_with_test.main(); + default_if_empty_test.main(); + delay_test.main(); + delay_when_test.main(); + dematerialize_test.main(); + distinct_test.main(); + distinct_unique_test.main(); + do_test.main(); + end_with_test.main(); + end_with_many_test.main(); + exhaust_map_test.main(); + flat_map_test.main(); + flat_map_iterable_test.main(); + group_by_test.main(); + ignore_elements_test.main(); + interval_test.main(); + join_test.main(); + map_not_null_test.main(); + map_to_test.main(); + materialize_test.main(); + merge_with_test.main(); + on_error_resume_test.main(); + on_error_return_test.main(); + on_error_return_with_test.main(); + scan_test.main(); + skip_last_test.main(); + skip_until_test.main(); + start_with_many_test.main(); + start_with_test.main(); + switch_if_empty_test.main(); + switch_map_test.main(); + take_last_test.main(); + take_until_test.main(); + take_while_inclusive_test.main(); + time_interval_test.main(); + timeout_test.main(); + timestamp_test.main(); + timer_test.main(); + where_not_null_test.main(); + where_type_test.main(); + with_latest_from_test.main(); + zip_with_test.main(); + + // Backpressure + buffer_test.main(); + buffer_count_test.main(); + buffer_test_test.main(); + buffer_time_test.main(); + debounce_test.main(); + debounce_time_test.main(); + pairwise_test.main(); + sample_test.main(); + sample_time_test.main(); + throttle_test.main(); + throttle_time_test.main(); + window_test.main(); + window_count_test.main(); + window_test_test.main(); + window_time_test.main(); + + // Subjects + behaviour_subject_test.main(); + publish_subject_test.main(); + replay_subject_test.main(); + + // Connectable Streams + value_connectable_stream_test.main(); + replay_connectable_stream_test.main(); + publish_connectable_stream_test.main(); + + // Utilities + composite_subscription_test.main(); + notification_test.main(); +} diff --git a/sandbox/reactivex/test/streams/combine_latest_test.dart b/sandbox/reactivex/test/streams/combine_latest_test.dart new file mode 100644 index 0000000..bd03e09 --- /dev/null +++ b/sandbox/reactivex/test/streams/combine_latest_test.dart @@ -0,0 +1,394 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream get streamA => + Stream.periodic(const Duration(milliseconds: 1), (int count) => count) + .take(3); + +Stream get streamB => Stream.fromIterable(const [1, 2, 3, 4]); + +Stream get streamC { + final controller = StreamController() + ..add(true) + ..close(); + + return controller.stream; +} + +void main() { + test('Rx.combineLatestList', () async { + final combined = Rx.combineLatestList([ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ]); + + expect( + combined, + emitsInOrder([ + [1, 2, 3], + [2, 2, 3], + [3, 2, 3], + ]), + ); + }); + + test('Rx.combineLatestList.iterate.once', () async { + var iterationCount = 0; + + final combined = Rx.combineLatestList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + combined, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.combineLatestList.empty', () async { + final combined = Rx.combineLatestList([]); + expect(combined, emitsDone); + }); + + test('Rx.combineLatest', () async { + final combined = Rx.combineLatest( + [ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ], + (values) => values.fold(0, (acc, val) => acc + val), + ); + + expect( + combined, + emitsInOrder([6, 7, 8]), + ); + }); + + test('Rx.combineLatest3', () async { + const expectedOutput = ['0 4 true', '1 4 true', '2 4 true']; + var count = 0; + + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result.compareTo(expectedOutput[count++]), 0); + }, count: 3)); + }); + + test('Rx.combineLatest3.single.subscription', () async { + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }); + + stream.listen(null); + await expectLater(() => stream.listen((_) {}), throwsA(isStateError)); + }); + + test('Rx.combineLatest2', () async { + const expected = [ + [1, 2], + [2, 2] + ]; + var count = 0; + + var a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = + Rx.combineLatest2(a, b, (int first, int second) => [first, second]); + + stream.listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.combineLatest2.throws', () async { + var a = Stream.value(1), b = Stream.value(2); + + final stream = Rx.combineLatest2(a, b, (int first, int second) { + throw Exception(); + }); + + stream.listen(null, onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.combineLatest3', () async { + const expected = [1, '2', 3.0]; + + var a = Stream.value(1), + b = Stream.value('2'), + c = Stream.value(3.0); + + final stream = Rx.combineLatest3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest4', () async { + const expected = [1, 2, 3, 4]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.combineLatest4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest5', () async { + const expected = [1, 2, 3, 4, 5]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.combineLatest5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest6', () async { + const expected = [1, 2, 3, 4, 5, 6]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.combineLatest6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest7', () async { + const expected = [1, 2, 3, 4, 5, 6, 7]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.combineLatest7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest8', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.combineLatest8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest9', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.combineLatest9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest.asBroadcastStream', () async { + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.combineLatest.error.shouldThrowA', () async { + final streamWithError = Rx.combineLatest4(Stream.value(1), Stream.value(1), + Stream.value(1), Stream.error(Exception()), + (int aValue, int bValue, int cValue, dynamic _) { + return '$aValue $bValue $cValue $_'; + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.combineLatest.error.shouldThrowB', () async { + final streamWithError = + Rx.combineLatest3(Stream.value(1), Stream.value(1), Stream.value(1), + (int aValue, int bValue, int cValue) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + /*test('Rx.combineLatest.error.shouldThrowC', () { + expect( + () => Rx.combineLatest3(Stream.value(1), + Stream.just(1), Stream.value(1), null), + throwsArgumentError); + }); + + test('Rx.combineLatest.error.shouldThrowD', () { + expect(() => CombineLatestStream(null, null), throwsArgumentError); + }); + + test('Rx.combineLatest.error.shouldThrowE', () { + expect(() => CombineLatestStream(>[], null), + throwsArgumentError); + });*/ + + test('Rx.combineLatest.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription> subscription; + // ignore: deprecated_member_use + subscription = Rx.combineLatest3( + first, second, last, (int a, int b, int c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 1); + expect(value.elementAt(1), 5); + expect(value.elementAt(2), 9); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); +} diff --git a/sandbox/reactivex/test/streams/concat_eager_test.dart b/sandbox/reactivex/test/streams/concat_eager_test.dart new file mode 100644 index 0000000..bb77624 --- /dev/null +++ b/sandbox/reactivex/test/streams/concat_eager_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]); + + return [a, b]; +} + +List> _getStreamsIncludingEmpty() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]), + c = Stream.empty(); + + return [c, a, b]; +} + +void main() { + test('Rx.concatEager', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concatEager(_getStreams()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.single', () async { + final stream = Rx.concatEager([ + Stream.fromIterable([1, 2, 3, 4, 5]) + ]); + + await expectLater(stream, emitsInOrder([1, 2, 3, 4, 5, emitsDone])); + }); + + test('Rx.concatEager.eagerlySubscription', () async { + var subscribed2 = false; + var subscribed3 = false; + + final stream = Rx.concatEager([ + Rx.timer(1, Duration(milliseconds: 100)).doOnDone( + expectAsync0(() => expect(subscribed2 && subscribed3, true))), + Rx.timer([2, 3, 4], Duration(milliseconds: 100)) + .exhaustMap((v) => Stream.fromIterable(v)) + .doOnListen(() => subscribed2 = true) + .doOnDone(expectAsync0(() => expect(subscribed3, true))), + Rx.timer(5, Duration(milliseconds: 100)) + .doOnListen(() => subscribed3 = true), + ]); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + 4, + 5, + emitsDone, + ]), + ); + }); + + test('Rx.concatEager.single.subscription', () async { + final stream = Rx.concatEager(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen((_) {}), throwsA(isStateError)); + }); + + test('Rx.concatEager.withEmptyStream', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concatEager(_getStreamsIncludingEmpty()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.withBroadcastStreams', () async { + const expectedOutput = [1, 2, 3, 4, 99, 98, 97, 96, 999, 998, 997]; + final ctrlA = StreamController.broadcast(), + ctrlB = StreamController.broadcast(), + ctrlC = StreamController.broadcast(); + var x = 0, y = 100, z = 1000, count = 0; + + Timer.periodic(const Duration(milliseconds: 10), (_) { + ctrlA.add(++x); + ctrlB.add(--y); + + if (x <= 3) ctrlC.add(--z); + + if (x == 3) ctrlC.close(); + + if (x == 4) { + _.cancel(); + + ctrlA.close(); + ctrlB.close(); + } + }); + + final stream = Rx.concatEager([ctrlA.stream, ctrlB.stream, ctrlC.stream]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.asBroadcastStream', () async { + final stream = Rx.concatEager(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.concatEager.error.shouldThrowA', () async { + final streamWithError = + Rx.concatEager(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.concatEager.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = + Rx.concatEager([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.concatEager.empty', () { + expect(Rx.concatEager(const []), emitsDone); + }); + + test('Rx.concatEager.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.concatEager(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); +} diff --git a/sandbox/reactivex/test/streams/concat_test.dart b/sandbox/reactivex/test/streams/concat_test.dart new file mode 100644 index 0000000..daacc24 --- /dev/null +++ b/sandbox/reactivex/test/streams/concat_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]); + + return [a, b]; +} + +List> _getStreamsIncludingEmpty() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]), + c = Stream.empty(); + + return [c, a, b]; +} + +void main() { + test('Rx.concat', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concat(_getStreams()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.single.subscription', () async { + final stream = Rx.concat(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.concat.withEmptyStream', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concat(_getStreamsIncludingEmpty()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concat.withBroadcastStreams', () async { + const expectedOutput = [1, 2, 3, 4]; + final ctrlA = StreamController.broadcast(), + ctrlB = StreamController.broadcast(), + ctrlC = StreamController.broadcast(); + var x = 0, y = 100, z = 1000, count = 0; + + Timer.periodic(const Duration(milliseconds: 1), (_) { + ctrlA.add(++x); + ctrlB.add(--y); + + if (x <= 3) ctrlC.add(--z); + + if (x == 3) ctrlC.close(); + + if (x == 4) { + _.cancel(); + + ctrlA.close(); + ctrlB.close(); + } + }); + + final stream = Rx.concat([ctrlA.stream, ctrlB.stream, ctrlC.stream]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concat.asBroadcastStream', () async { + final stream = Rx.concat(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.concat.error.shouldThrowA', () async { + final streamWithError = + Rx.concat(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.concat.empty', () { + expect(Rx.concat(const []), emitsDone); + }); + + test('Rx.concat.single', () { + expect( + Rx.concat([Stream.value(1)]), + emitsInOrder([1, emitsDone]), + ); + }); + + test('Rx.concat.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.concat(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); +} diff --git a/sandbox/reactivex/test/streams/defer_test.dart b/sandbox/reactivex/test/streams/defer_test.dart new file mode 100644 index 0000000..5ff00cc --- /dev/null +++ b/sandbox/reactivex/test/streams/defer_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.defer', () async { + const value = 1; + + final stream = _getDeferStream(); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + }); + + test('Rx.defer.multiple.listeners', () async { + const value = 1; + + final stream = _getBroadcastDeferStream(); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + }); + + test('Rx.defer.streamFactory.called', () async { + var count = 0; + + Stream streamFactory() { + ++count; + return Stream.value(1); + } + + var deferStream = DeferStream( + streamFactory, + reusable: false, + ); + + expect(count, 0); + + deferStream.listen( + expectAsync1((_) { + expect(count, 1); + }), + ); + }); + + test('Rx.defer.reusable', () async { + const value = 1; + + final stream = Rx.defer( + () => Stream.fromFuture( + Future.delayed( + Duration(seconds: 1), + () => value, + ), + ), + reusable: true, + ); + + stream.listen( + expectAsync1( + (actual) => expect(actual, value), + count: 1, + ), + ); + stream.listen( + expectAsync1( + (actual) => expect(actual, value), + count: 1, + ), + ); + }); + + test('Rx.defer.single.subscription', () async { + final stream = _getDeferStream(); + + try { + stream.listen(null); + stream.listen(null); + expect(true, false); + } catch (e) { + expect(e, isStateError); + } + }); + + test('Rx.defer.error.shouldThrow.A', () async { + final streamWithError = Rx.defer(() => _getErroneousStream()); + + streamWithError.listen(null, + onError: expectAsync1((Exception e) { + expect(e, isException); + }, count: 1)); + }); + + test('Rx.defer.error.shouldThrow.B', () { + final deferStream1 = Rx.defer(() => throw Exception()); + expect( + deferStream1, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + final deferStream2 = Rx.defer(() => throw Exception(), reusable: true); + expect( + deferStream2, + emitsInOrder([emitsError(isException), emitsDone]), + ); + }); +} + +Stream _getDeferStream() => Rx.defer(() => Stream.value(1)); + +Stream _getBroadcastDeferStream() => + Rx.defer(() => Stream.value(1)).asBroadcastStream(); + +Stream _getErroneousStream() { + final controller = StreamController(); + + controller.addError(Exception()); + controller.close(); + + return controller.stream; +} diff --git a/sandbox/reactivex/test/streams/fork_join_test.dart b/sandbox/reactivex/test/streams/fork_join_test.dart new file mode 100644 index 0000000..5708581 --- /dev/null +++ b/sandbox/reactivex/test/streams/fork_join_test.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream get streamA => + Stream.periodic(const Duration(milliseconds: 1), (int count) => count) + .take(3); + +Stream get streamB => Stream.fromIterable(const [1, 2, 3, 4]); + +Stream get streamC { + final controller = StreamController() + ..add(true) + ..close(); + + return controller.stream; +} + +void main() { + test('Rx.forkJoinList', () async { + final combined = Rx.forkJoinList([ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ]); + + await expectLater( + combined, + emitsInOrder([ + [3, 2, 3], + emitsDone + ]), + ); + }); + + test('Rx.forkJoin.nullable', () { + expect( + ForkJoinStream.join2( + Stream.value(null), + Stream.value(1), + (a, b) => '$a $b', + ), + emitsInOrder([ + 'null 1', + emitsDone, + ]), + ); + }); + + test('Rx.forkJoin.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.forkJoinList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.forkJoin.empty', () { + expect(Rx.forkJoinList([]), emitsDone); + }); + + test('Rx.forkJoinList.singleStream', () async { + final combined = Rx.forkJoinList([ + Stream.fromIterable([1, 2, 3]) + ]); + + await expectLater( + combined, + emitsInOrder([ + [3], + emitsDone + ]), + ); + }); + + test('Rx.forkJoin', () async { + final combined = Rx.forkJoin( + [ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ], + (values) => values.fold(0, (acc, val) => acc + val), + ); + + await expectLater( + combined, + emitsInOrder([8, emitsDone]), + ); + }); + + test('Rx.forkJoin3', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue'); + + await expectLater(stream, emitsInOrder(['2 4 true', emitsDone])); + }); + + test('Rx.forkJoin3.single.subscription', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue'); + + await expectLater( + stream, + emitsInOrder(['2 4 true', emitsDone]), + ); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.forkJoin2', () async { + var a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = + Rx.forkJoin2(a, b, (int first, int second) => [first, second]); + + await expectLater( + stream, + emitsInOrder([ + [2, 2], + emitsDone + ])); + }); + + test('Rx.forkJoin2.throws', () async { + var a = Stream.value(1), b = Stream.value(2); + + final stream = Rx.forkJoin2(a, b, (int first, int second) { + throw Exception(); + }); + + stream.listen(null, onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.forkJoin3', () async { + var a = Stream.value(1), + b = Stream.value('2'), + c = Stream.value(3.0); + + final stream = Rx.forkJoin3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + await expectLater( + stream, + emitsInOrder([ + const [1, '2', 3.0], + emitsDone + ])); + }); + + test('Rx.forkJoin4', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.forkJoin4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4], + emitsDone + ])); + }); + + test('Rx.forkJoin5', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.forkJoin5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5], + emitsDone + ])); + }); + + test('Rx.forkJoin6', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.combineLatest6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6], + emitsDone + ])); + }); + + test('Rx.forkJoin7', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.forkJoin7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7], + emitsDone + ])); + }); + + test('Rx.forkJoin8', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.forkJoin8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7, 8], + emitsDone + ])); + }); + + test('Rx.forkJoin9', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.forkJoin9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7, 8, 9], + emitsDone + ])); + }); + + test('Rx.forkJoin.asBroadcastStream', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue') + .asBroadcastStream(); + +// listen twice on same stream + stream.listen(null); + stream.listen(null); +// code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.forkJoin.error.shouldThrowA', () async { + final streamWithError = Rx.forkJoin4( + Stream.value(1), + Stream.value(1), + Stream.value(1), + Stream.error(Exception()), + (int aValue, int bValue, int cValue, dynamic _) => + '$aValue $bValue $cValue $_'); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }), cancelOnError: true); + }); + + test('Rx.forkJoin.error.shouldThrowB', () async { + final streamWithError = + Rx.forkJoin3(Stream.value(1), Stream.value(1), Stream.value(1), + (int aValue, int bValue, int cValue) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.forkJoin.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]).take(4), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]).take(4), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]).take(4); + + late StreamSubscription> subscription; + subscription = + Rx.forkJoin3(first, second, last, (int a, int b, int c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 4); + expect(value.elementAt(1), 8); + expect(value.elementAt(2), 12); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.forkJoin.completed', () async { + final stream = Rx.forkJoin2( + Stream.empty(), + Stream.value(1), + (int a, int b) => a + b, + ); + await expectLater( + stream, + emitsInOrder([emitsError(isStateError), emitsDone]), + ); + }); + + test('Rx.forkJoin.error.shouldThrowC', () async { + final stream = Rx.forkJoin2( + Stream.value(1), + Stream.error(Exception()).concatWith([ + Rx.timer( + 2, + const Duration(milliseconds: 100), + ) + ]), + (int a, int b) => a + b, + ); + await expectLater( + stream, + emitsInOrder([emitsError(isException), 3, emitsDone]), + ); + }); + + test('Rx.forkJoin.error.shouldThrowD', () async { + final stream = Rx.forkJoin2( + Stream.value(1), + Stream.error(Exception()).concatWith([ + Rx.timer( + 2, + const Duration(milliseconds: 100), + ) + ]), + (int a, int b) => a + b, + ); + + stream.listen( + expectAsync1((value) {}, count: 0), + onError: expectAsync2( + (Object e, StackTrace s) => expect(e, isException), + count: 1, + ), + cancelOnError: true, + ); + }); +} diff --git a/sandbox/reactivex/test/streams/from_callable_test.dart b/sandbox/reactivex/test/streams/from_callable_test.dart new file mode 100644 index 0000000..69b7dca --- /dev/null +++ b/sandbox/reactivex/test/streams/from_callable_test.dart @@ -0,0 +1,130 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.fromCallable.sync', () { + var called = false; + + var stream = Rx.fromCallable(() { + called = true; + return 2; + }); + + expect(called, false); + expectLater(stream, emitsInOrder([2, emitsDone])); + expect(called, true); + }); + + test('Rx.fromCallable.async', () { + var called = false; + + var stream = FromCallableStream(() async { + called = true; + await Future.delayed(const Duration(milliseconds: 10)); + return 2; + }); + + expect(called, false); + expectLater(stream, emitsInOrder([2, emitsDone])); + expect(called, true); + }); + + test('Rx.fromCallable.reusable', () { + var stream = Rx.fromCallable(() => 2, reusable: true); + expect(stream.isBroadcast, isTrue); + + stream.listen(null); + stream.listen(null); + + expect(true, true); + }); + + test('Rx.fromCallable.singleSubscription', () { + { + var stream = Rx.fromCallable(() => + Future.delayed(const Duration(milliseconds: 10), () => 'Value')); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + } + + { + var stream = Rx.fromCallable(() => Future.error(Exception())); + + expect(stream.isBroadcast, isFalse); + stream.listen(null, onError: (Object e) {}); + expect( + () => stream.listen(null, onError: (Object e) {}), throwsStateError); + } + }); + + test('Rx.fromCallable.asBroadcastStream', () async { + final stream = Rx.fromCallable(() => 2).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.fromCallable.sync.shouldThrow', () { + var stream = Rx.fromCallable(() => throw Exception()); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + }); + + test('Rx.fromCallable.async.shouldThrow', () { + { + var stream = Rx.fromCallable(() async => throw Exception()); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + } + + { + var stream = Rx.fromCallable(() => Future.error(Exception())); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + } + }); + + test('Rx.fromCallable.sync.pause.resume', () { + var stream = Rx.fromCallable(() => 'Value'); + + stream + .listen( + expectAsync1( + (v) => expect(v, 'Value'), + count: 1, + ), + ) + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); + + test('Rx.fromCallable.async.pause.resume', () { + var stream = Rx.fromCallable(() async { + await Future.delayed(const Duration(milliseconds: 10)); + return 'Value'; + }); + + stream + .listen( + expectAsync1( + (v) => expect(v, 'Value'), + count: 1, + ), + ) + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); +} diff --git a/sandbox/reactivex/test/streams/merge_test.dart b/sandbox/reactivex/test/streams/merge_test.dart new file mode 100644 index 0000000..b70975e --- /dev/null +++ b/sandbox/reactivex/test/streams/merge_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(3), + b = Stream.fromIterable(const [1, 2, 3, 4]); + + return [a, b]; +} + +void main() { + test('Rx.merge', () async { + final stream = Rx.merge(_getStreams()); + + await expectLater(stream, emitsInOrder(const [1, 2, 3, 4, 0, 1, 2])); + }); + + test('Rx.merge.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.merge(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.merge.single.subscription', () async { + final stream = Rx.merge(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.merge.asBroadcastStream', () async { + final stream = Rx.merge(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.merge.error.shouldThrowA', () async { + final streamWithError = + Rx.merge(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.merge.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = Rx.merge([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.merge.empty', () { + expect(Rx.merge(const []), emitsDone); + }); +} diff --git a/sandbox/reactivex/test/streams/never_test.dart b/sandbox/reactivex/test/streams/never_test.dart new file mode 100644 index 0000000..4254963 --- /dev/null +++ b/sandbox/reactivex/test/streams/never_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('NeverStream', () async { + var onDataCalled = false, onDoneCalled = false, onErrorCalled = false; + + final stream = NeverStream(); + + final subscription = stream.listen( + expectAsync1((_) { + onDataCalled = true; + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + onErrorCalled = false; + }, count: 0), + onDone: expectAsync0(() { + onDataCalled = true; + }, count: 0)); + + await Future.delayed(Duration(milliseconds: 10)); + + await subscription.cancel(); + + // We do not expect onData, onDone, nor onError to be called, as [never] + // streams emit no items or errors, and they do not terminate + await expectLater(onDataCalled, isFalse); + await expectLater(onDoneCalled, isFalse); + await expectLater(onErrorCalled, isFalse); + }); + + test('NeverStream.single.subscription', () async { + final stream = NeverStream(); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.never', () async { + var onDataCalled = false, onDoneCalled = false, onErrorCalled = false; + + final stream = Rx.never(); + + final subscription = stream.listen( + expectAsync1((_) { + onDataCalled = true; + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + onErrorCalled = false; + }, count: 0), + onDone: expectAsync0(() { + onDataCalled = true; + }, count: 0)); + + await Future.delayed(Duration(milliseconds: 10)); + + await subscription.cancel(); + + // We do not expect onData, onDone, nor onError to be called, as [never] + // streams emit no items or errors, and they do not terminate + await expectLater(onDataCalled, isFalse); + await expectLater(onDoneCalled, isFalse); + await expectLater(onErrorCalled, isFalse); + }); +} diff --git a/sandbox/reactivex/test/streams/publish_connectable_stream_test.dart b/sandbox/reactivex/test/streams/publish_connectable_stream_test.dart new file mode 100644 index 0000000..4e1e793 --- /dev/null +++ b/sandbox/reactivex/test/streams/publish_connectable_stream_test.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('PublishConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = PublishConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + final ConnectableStream stream = PublishConnectableStream( + Stream.fromIterable([1, 2, 3])); + + stream.connect(); + + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('stops emitting after the connection is cancelled', () async { + final ConnectableStream stream = + Stream.fromIterable([1, 2, 3]).publishValue(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('multicasts a single-subscription stream', () async { + final stream = PublishConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).publish(); + + stream.connect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('refcount automatically connects', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).share(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publish() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.share(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + }); + + test('can close share() stream', () async { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .share() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + expect(isCanceled.future, completes); + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + PublishConnectableStream stream() => Stream.value(1).publish(); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/sandbox/reactivex/test/streams/race_test.dart b/sandbox/reactivex/test/streams/race_test.dart new file mode 100644 index 0000000..ef932e4 --- /dev/null +++ b/sandbox/reactivex/test/streams/race_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream getDelayedStream(int delay, int value) async* { + final completer = Completer(); + + Timer(Duration(milliseconds: delay), () => completer.complete()); + + await completer.future; + + yield value; + yield value + 1; + yield value + 2; +} + +void main() { + test('Rx.race', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + var expected = 1; + + Rx.race([first, second, last]).listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result.compareTo(expected++), 0); + }, count: 3)); + }); + + test('Rx.race.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.race(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([1, emitsDone]), + ); + expect(iterationCount, 1); + }); + + test('Rx.race.single.subscription', () async { + final first = getDelayedStream(50, 1); + + final stream = Rx.race([first]); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.race.asBroadcastStream', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + + final stream = Rx.race([first, second, last]).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.race.shouldThrowB', () async { + final stream = Rx.race([Stream.error(Exception('oh noes!'))]); + + // listen twice on same stream + stream.listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException))); + }); + + test('Rx.race.pause.resume', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = Rx.race([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.race.empty', () { + expect(Rx.race(const []), emitsDone); + }); + + test('Rx.race.single', () { + expect( + Rx.race([Stream.value(1)]), + emitsInOrder([ + 1, + emitsDone, + ]), + ); + }); + + test('Rx.race.cancel.throws', () async { + Stream stream() { + final controller = StreamController(); + controller.onCancel = () async { + throw Exception('Exception when cancelling!'); + }; + + return Rx.race([ + controller.stream, + Rx.concat([ + Rx.timer(1, const Duration(milliseconds: 100)), + Rx.timer(2, const Duration(milliseconds: 100)), + ]), + ]); + } + + await expectLater( + stream(), + emitsInOrder([1, emitsError(isException), 2, emitsDone]), + ); + + await expectLater( + stream().take(1), + emitsInOrder([1, emitsDone]), + ); + }); +} diff --git a/sandbox/reactivex/test/streams/range_test.dart b/sandbox/reactivex/test/streams/range_test.dart new file mode 100644 index 0000000..e57739f --- /dev/null +++ b/sandbox/reactivex/test/streams/range_test.dart @@ -0,0 +1,52 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('RangeStream', () async { + final expected = const [1, 2, 3]; + var count = 0; + + final stream = RangeStream(1, 3); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); + + test('RangeStream.single.subscription', () async { + final stream = RangeStream(1, 5); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('RangeStream.single', () async { + final stream = RangeStream(1, 1); + + stream.listen(expectAsync1((actual) { + expect(actual, 1); + }, count: 1)); + }); + + test('RangeStream.reverse', () async { + final expected = const [3, 2, 1]; + var count = 0; + + final stream = RangeStream(3, 1); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.range', () async { + final expected = const [1, 2, 3]; + var count = 0; + + final stream = Rx.range(1, 3); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); +} diff --git a/sandbox/reactivex/test/streams/repeat_test.dart b/sandbox/reactivex/test/streams/repeat_test.dart new file mode 100644 index 0000000..f16196f --- /dev/null +++ b/sandbox/reactivex/test/streams/repeat_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.repeat', () async { + const retries = 3; + + await expectLater(Rx.repeat(_getRepeatStream('A'), retries), + emitsInOrder(['A0', 'A1', 'A2', emitsDone])); + }); + + test('RepeatStream', () async { + const retries = 3; + + await expectLater(RepeatStream(_getRepeatStream('A'), retries), + emitsInOrder(['A0', 'A1', 'A2', emitsDone])); + }); + + test('RepeatStream.onDone', () async { + const retries = 0; + + await expectLater(RepeatStream(_getRepeatStream('A'), retries), emitsDone); + }); + + test('RepeatStream.infinite.repeats', () async { + await expectLater( + RepeatStream(_getRepeatStream('A')), emitsThrough('A100')); + }); + + test('RepeatStream.single.subscription', () async { + const retries = 3; + + final stream = RepeatStream(_getRepeatStream('A'), retries); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('RepeatStream.asBroadcastStream', () async { + const retries = 3; + + final stream = + RepeatStream(_getRepeatStream('A'), retries).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('RepeatStream.error.shouldThrow', () async { + final streamWithError = RepeatStream(_getErroneusRepeatStream('A'), 2); + + await expectLater( + streamWithError, + emitsInOrder([ + 'A0', + emitsError(TypeMatcher()), + 'A0', + emitsError(TypeMatcher()), + emitsDone + ])); + }); + + test('RepeatStream.pause.resume', () async { + late StreamSubscription subscription; + const retries = 3; + + subscription = RepeatStream(_getRepeatStream('A'), retries) + .listen(expectAsync1((result) { + expect(result, 'A0'); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); +} + +Stream Function(int) _getRepeatStream(String symbol) => + (int repeatIndex) async* { + yield await Future.delayed( + const Duration(milliseconds: 20), () => '$symbol$repeatIndex'); + }; + +Stream Function(int) _getErroneusRepeatStream(String symbol) => + (int repeatIndex) { + return Stream.value('A0') + // Emit the error + .concatWith([Stream.error(Error())]); + }; diff --git a/sandbox/reactivex/test/streams/replay_connectable_stream_test.dart b/sandbox/reactivex/test/streams/replay_connectable_stream_test.dart new file mode 100644 index 0000000..54b1527 --- /dev/null +++ b/sandbox/reactivex/test/streams/replay_connectable_stream_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('ReplayConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = ReplayConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + const items = [1, 2, 3]; + final stream = ReplayConnectableStream(Stream.fromIterable(items)); + + stream.connect(); + + expect(stream, emitsInOrder(items)); + stream.listen(expectAsync1((int i) { + expect(stream.values, items.sublist(0, i)); + }, count: items.length)); + }); + + test('stops emitting after the connection is cancelled', () async { + final ConnectableStream stream = + Stream.fromIterable([1, 2, 3]).publishReplay(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('stops emitting after the last subscriber unsubscribes', () async { + final Stream stream = + Stream.fromIterable([1, 2, 3]).shareReplay(); + + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('keeps emitting with an active subscription', () async { + final Stream stream = + Stream.fromIterable([1, 2, 3]).shareReplay(); + + stream.listen(null); + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('multicasts a single-subscription stream', () async { + final Stream stream = ReplayConnectableStream( + Stream.fromIterable([1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('replays the max number of items', () async { + final Stream stream = ReplayConnectableStream( + Stream.fromIterable([1, 2, 3]), + maxSize: 2, + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emitsInOrder([2, 3])); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('only holds a certain number of values', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + expect(stream.values, const []); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('provides access to all items', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + stream.listen(expectAsync1((int data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.values, items); + } + }, count: items.length)); + }); + + test('provides access to a certain number of items', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = + Stream.fromIterable(const [1, 2, 3]).shareReplay(maxSize: 2); + + stream.listen(expectAsync1((data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.values, const [2, 3]); + } + }, count: items.length)); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publishReplay() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareReplay(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + }); + + test('can close shareReplay() stream', () async { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareReplay() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + expect(isCanceled.future, completes); + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + ReplayConnectableStream stream() => + Stream.value(1).publishReplay(maxSize: 1); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/sandbox/reactivex/test/streams/retry_test.dart b/sandbox/reactivex/test/streams/retry_test.dart new file mode 100644 index 0000000..10ee4fd --- /dev/null +++ b/sandbox/reactivex/test/streams/retry_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.retry', () async { + const retries = 3; + + await expectLater(Rx.retry(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream', () async { + const retries = 3; + + await expectLater(RetryStream(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.onDone', () async { + const retries = 3; + + await expectLater(RetryStream(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.infinite.retries', () async { + await expectLater(RetryStream(_getRetryStream(1000)), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.emits.original.items', () async { + const retries = 3; + + await expectLater(RetryStream(_getStreamWithExtras(retries), retries), + emitsInOrder([1, 1, 1, 2, emitsDone])); + }); + + test('RetryStream.single.subscription', () async { + const retries = 3; + + final stream = RetryStream(_getRetryStream(retries), retries); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('RetryStream.asBroadcastStream', () async { + const retries = 3; + + final stream = + RetryStream(_getRetryStream(retries), retries).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('RetryStream.error.shouldThrow', () async { + final streamWithError = RetryStream(_getRetryStream(3), 2); + + await expectLater( + streamWithError, + emitsInOrder( + [ + emitsError(isA()), + emitsError(isA()), + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('RetryStream.error.capturesErrors', () { + RetryStream(_getRetryStream(3), 2).listen( + expectAsync1((_) {}, count: 0), + onError: expectAsync2( + (Object e, StackTrace st) { + expect(e, isA()); + expect(st, isNotNull); + }, + count: 3, + ), + onDone: expectAsync0(() {}, count: 1), + ); + }); + + test('RetryStream.pause.resume', () async { + late StreamSubscription subscription; + const retries = 3; + + subscription = RetryStream(_getRetryStream(retries), retries) + .listen(expectAsync1((result) { + expect(result, 1); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); +} + +Stream Function() _getRetryStream(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + return Stream.error(Error(), StackTrace.fromString('S')); + } else { + return Stream.value(1); + } + }; +} + +Stream Function() _getStreamWithExtras(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + + // Emit first item + return Stream.value(1) + // Emit the error + .concatWith([Stream.error(Error())]) + // Emit an extra item, testing that it is not included + .concatWith([Stream.value(1)]); + } else { + return Stream.value(2); + } + }; +} diff --git a/sandbox/reactivex/test/streams/retry_when_test.dart b/sandbox/reactivex/test/streams/retry_when_test.dart new file mode 100644 index 0000000..3d139b6 --- /dev/null +++ b/sandbox/reactivex/test/streams/retry_when_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.retryWhen', () { + expect( + Rx.retryWhen(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream', () { + expect( + RetryWhenStream(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.onDone', () { + expect( + RetryWhenStream(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.infinite.retries', () { + expect( + RetryWhenStream(_sourceStream(1000, 2), _neverThrow).take(6), + emitsInOrder([0, 1, 0, 1, 0, 1, emitsDone]), + ); + }); + + test('RetryWhenStream.emits.original.items', () { + const retries = 3; + + expect( + RetryWhenStream(_getStreamWithExtras(retries), _neverThrow).take(6), + emitsInOrder([1, 1, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.single.subscription', () { + final stream = RetryWhenStream(_sourceStream(3), _neverThrow); + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + expect(e, isStateError); + } + }); + + test('RetryWhenStream.asBroadcastStream', () { + final stream = + RetryWhenStream(_sourceStream(3), _neverThrow).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + expect(stream.isBroadcast, isTrue); + }); + + test('RetryWhenStream.error.shouldThrow', () { + final streamWithError = RetryWhenStream(_sourceStream(3, 0), _alwaysThrow); + + expect( + streamWithError, + emitsInOrder( + [ + emitsError(0), + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('RetryWhenStream.error.capturesErrors', () async { + final streamWithError = RetryWhenStream(_sourceStream(3, 0), _alwaysThrow); + + await expectLater( + streamWithError, + emitsInOrder([ + emitsError(0), + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('RetryWhenStream.pause.resume', () async { + late StreamSubscription subscription; + + subscription = RetryWhenStream(_sourceStream(3), _neverThrow) + .listen(expectAsync1((result) { + expect(result, 0); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('RetryWhenStream.cancel.ensureSubStreamCancels', () async { + var isCancelled = false, didStopEmitting = true; + Stream subStream(Object e, StackTrace s) => + Stream.periodic(const Duration(milliseconds: 100), (count) => count) + .doOnData((_) { + if (isCancelled) { + didStopEmitting = false; + } + }); + final subscription = + RetryWhenStream(_sourceStream(3, 0), subStream).listen(null); + + await Future.delayed(const Duration(milliseconds: 250)); + + await subscription.cancel(); + isCancelled = true; + + await Future.delayed(const Duration(milliseconds: 250)); + + expect(didStopEmitting, isTrue); + }); + + test('RetryWhenStream.retryStream.throws.originError', () { + final error = 1; + final stream = Rx.retryWhen( + _sourceStream(3, error), + (error, stackTrace) => Stream.error(error), + ); + expect( + stream, + emitsInOrder([ + 0, + emitsError(error), + emitsDone, + ]), + ); + }); + + test('RetryWhenStream.streamFactory.throws.originError', () { + final error = 1; + final stream = Rx.retryWhen( + _sourceStream(3, error), + (error, stackTrace) => throw error, + ); + expect( + stream, + emitsInOrder([ + 0, + emitsError(error), + emitsDone, + ]), + ); + }); +} + +Stream Function() _sourceStream(int i, [int? throwAt]) { + return throwAt == null + ? () => Stream.fromIterable(range(i)) + : () => + Stream.fromIterable(range(i)).map((i) => i == throwAt ? throw i : i); +} + +Stream _alwaysThrow(dynamic e, StackTrace s) => + Stream.error(Error(), StackTrace.fromString('S')); + +Stream _neverThrow(dynamic e, StackTrace s) => Stream.value(null); + +Stream Function() _getStreamWithExtras(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + + // Emit first item + return Stream.value(1) + // Emit the error + .concatWith([Stream.error(Error())]) + // Emit an extra item, testing that it is not included + .concatWith([Stream.value(1)]); + } else { + return Stream.value(2); + } + }; +} + +/// Returns an [Iterable] sequence of [int]s. +/// +/// If only one argument is provided, [startOrStop] is the upper bound for +/// the sequence. If two or more arguments are provided, [stop] is the upper +/// bound. +/// +/// The sequence starts at 0 if one argument is provided, or [startOrStop] if +/// two or more arguments are provided. The sequence increments by 1, or [step] +/// if provided. [step] can be negative, in which case the sequence counts down +/// from the starting point and [stop] must be less than the starting point so +/// that it becomes the lower bound. +Iterable range(int startOrStop, [int? stop, int? step]) sync* { + final start = stop == null ? 0 : startOrStop; + stop ??= startOrStop; + step ??= 1; + + if (step == 0) throw ArgumentError('step cannot be 0'); + if (step > 0 && stop < start) { + throw ArgumentError('if step is positive,' + ' stop must be greater than start'); + } + if (step < 0 && stop > start) { + throw ArgumentError('if step is negative,' + ' stop must be less than start'); + } + + for (var value = start; + step < 0 ? value > stop : value < stop; + value += step) { + yield value; + } +} diff --git a/sandbox/reactivex/test/streams/sequence_equals_test.dart b/sandbox/reactivex/test/streams/sequence_equals_test.dart new file mode 100644 index 0000000..4cbc1d7 --- /dev/null +++ b/sandbox/reactivex/test/streams/sequence_equals_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.sequenceEqual.equals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.diffTime.equals', () async { + final stream = Rx.sequenceEqual( + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1) + .take(5), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.customCompare.equals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 1, 1, 1, 1]), + Stream.fromIterable(const [2, 2, 2, 2, 2]), + equals: (int? a, int? b) => true); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.diffTime.notEquals', () async { + final stream = Rx.sequenceEqual( + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1) + .take(5), + Stream.fromIterable(const [1, 1, 1, 1, 1])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 5, 4])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.customCompare.notEquals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 1, 1, 1, 1]), + Stream.fromIterable(const [1, 1, 1, 1, 1]), + equals: (int? a, int? b) => false); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.differentLength', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.differentLength.customCompare.notEquals', + () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6]), + equals: (int? a, int? b) => true); + + // expect false, + // even if the equals handler always returns true, + // the emitted events length is different + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.errors', () async { + final stream = Rx.sequenceEqual( + Stream.error(ArgumentError('error A')), + Stream.error(ArgumentError('error A')), + errorEquals: (e1, e2) => e1.error.toString() == e2.error.toString(), + ); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.errors', () async { + final stream = Rx.sequenceEqual( + Stream.error(ArgumentError('error A')), + Stream.error(ArgumentError('error B')), + errorEquals: (e1, e2) => e1.error.toString() == e2.error.toString(), + ); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.single.subscription', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.sequenceEqual.asBroadcastStream', () async { + final future = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])) + .asBroadcastStream() + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); +} diff --git a/sandbox/reactivex/test/streams/switch_latest_test.dart b/sandbox/reactivex/test/streams/switch_latest_test.dart new file mode 100644 index 0000000..ea3f055 --- /dev/null +++ b/sandbox/reactivex/test/streams/switch_latest_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('SwitchLatest', () { + test('emits all values from an emitted Stream', () { + expect( + Rx.switchLatest( + Stream.value( + Stream.fromIterable(const ['A', 'B', 'C']), + ), + ), + emitsInOrder(['A', 'B', 'C', emitsDone]), + ); + }); + + test('only emits values from the latest emitted stream', () { + expect( + Rx.switchLatest(testStream), + emits('C'), + ); + }); + + test('emits errors from the higher order Stream to the listener', () { + expect( + Rx.switchLatest( + Stream>.error(Exception()), + ), + emitsError(isException), + ); + }); + + test('emits errors from the emitted Stream to the listener', () { + expect( + Rx.switchLatest(errorStream), + emitsError(isException), + ); + }); + + test('closes after the last event from the last emitted Stream', () { + expect( + Rx.switchLatest(testStream), + emitsThrough(emitsDone), + ); + }); + + test('closes if the higher order stream is empty', () { + expect( + Rx.switchLatest( + Stream>.empty(), + ), + emitsThrough(emitsDone), + ); + }); + + test('is single subscription', () { + final stream = SwitchLatestStream(testStream); + + expect(stream, emits('C')); + expect(() => stream.listen(null), throwsStateError); + }); + + test('can be paused and resumed', () { + // ignore: cancel_subscriptions + final subscription = + Rx.switchLatest(testStream).listen(expectAsync1((result) { + expect(result, 'C'); + })); + + subscription.pause(); + subscription.resume(); + }); + }); +} + +Stream> get testStream => Stream.fromIterable([ + Rx.timer('A', Duration(seconds: 2)), + Rx.timer('B', Duration(seconds: 1)), + Stream.value('C'), + ]); + +Stream> get errorStream => Stream.fromIterable([ + Rx.timer('A', Duration(seconds: 2)), + Rx.timer('B', Duration(seconds: 1)), + Stream.error(Exception()), + ]); diff --git a/sandbox/reactivex/test/streams/timer_test.dart b/sandbox/reactivex/test/streams/timer_test.dart new file mode 100644 index 0000000..4f5f62e --- /dev/null +++ b/sandbox/reactivex/test/streams/timer_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('TimerStream', () async { + const value = 1; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + await expectLater(stream, emitsInOrder([value, emitsDone])); + }); + + test('TimerStream.single.subscription', () async { + final stream = TimerStream(1, Duration(milliseconds: 1)); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('TimerStream.pause.resume.A', () async { + const value = 1; + late StreamSubscription subscription; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + subscription = stream.listen(expectAsync1((actual) { + expect(actual, value); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('TimerStream.pause.resume.B', () async { + const seconds = 2; + const delay = 1; + + var stream = Rx.timer(99, const Duration(seconds: seconds)); + var stopwatch = Stopwatch()..start(); + var subscription = stream.listen(expectAsync1((_) { + stopwatch.stop(); + expect(stopwatch.elapsed.inSeconds, seconds + delay); + })); + + await Future.delayed(const Duration(milliseconds: 100)); + subscription.pause(); + subscription.pause(); + + await Future.delayed(const Duration(seconds: delay)); + + subscription.resume(); + subscription.resume(); + subscription.resume(); + }); + + test('TimerStream.pause.resume.C', () async { + const value = 1; + const delta = Duration(milliseconds: 100); + const duration = Duration(seconds: 1); + final stream = TimerStream(value, duration); + + var elapses = Duration.zero; + late Stopwatch watch; + + void startWatch() => watch = Stopwatch()..start(); + + Future delay() => + Future.delayed(const Duration(milliseconds: 200)); + + void stopWatch() => elapses = elapses + watch.elapsed; + + final subscription = stream.listen(expectAsync1((actual) { + expect(actual, value); + + stopWatch(); + expect( + duration - delta <= elapses && elapses <= duration + delta, + isTrue, + ); + })); + startWatch(); + + await delay(); + + subscription.pause(); + stopWatch(); + + await delay(); + + subscription.resume(); + startWatch(); + }); + + test('TimerStream.single.subscription', () async { + final stream = TimerStream(null, Duration(milliseconds: 1)); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('TimerStream.cancel', () async { + const value = 1; + StreamSubscription subscription; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + subscription = stream.listen( + expectAsync1((_) { + expect(true, isFalse); + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + expect(true, isFalse); + }, count: 0), + onDone: expectAsync0(() { + expect(true, isFalse); + }, count: 0)); + + await subscription.cancel(); + }); + + test('Rx.timer', () async { + const value = 1; + + final stream = Rx.timer(value, Duration(milliseconds: 5)); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }), onDone: expectAsync0(() { + expect(true, isTrue); + })); + }); +} diff --git a/sandbox/reactivex/test/streams/using_test.dart b/sandbox/reactivex/test/streams/using_test.dart new file mode 100644 index 0000000..fcce284 --- /dev/null +++ b/sandbox/reactivex/test/streams/using_test.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +const resourceDuration = Duration(milliseconds: 5); + +class MockResource { + var _closed = false; + + bool get isClosed => _closed; + + MockResource(); + + Future close() { + if (_closed) { + throw StateError('Resource has already been closed.'); + } + _closed = true; + return Future.delayed(resourceDuration); + } + + void closeSync() { + if (_closed) { + throw StateError('Resource has already been closed.'); + } + _closed = true; + } +} + +enum Close { + sync, + async, +} + +enum Create { + sync, + async, +} + +void main() async { + for (final close in Close.values) { + for (final create in Create.values) { + final groupPrefix = + 'Rx.using.${create.toString().toLowerCase()}.${close.toString().toLowerCase()}'; + + group(groupPrefix, () { + late MockResource resource; + var isResourceCreated = false; + + late FutureOr Function() resourceFactory; + late FutureOr Function() resourceFactoryThrows; + + late FutureOr Function(MockResource) disposer; + late FutureOr Function(MockResource) disposerThrows; + + setUp(() { + isResourceCreated = false; + + resourceFactory = () { + switch (create) { + case Create.sync: + isResourceCreated = true; + return resource = MockResource(); + case Create.async: + return Future.delayed( + resourceDuration, + () { + isResourceCreated = true; + return resource = MockResource(); + }, + ); + } + }; + + resourceFactoryThrows = () { + switch (create) { + case Create.sync: + throw Exception(); + case Create.async: + return Future.delayed( + resourceDuration, + () => throw Exception(), + ); + } + }; + + disposer = (resource) { + switch (close) { + case Close.async: + return resource.close(); + case Close.sync: + // ignore: unnecessary_cast + return resource.closeSync() as FutureOr; + } + }; + + disposerThrows = (resource) { + switch (close) { + case Close.async: + return Future.delayed( + resourceDuration, + () => throw Exception(), + ); + case Close.sync: + throw Exception(); + } + }; + }); + + test('$groupPrefix.done', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.value(resource) + .flatMap((_) => Stream.fromIterable([1, 2, 3])), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.resourceFactory.throws', () async { + var calledStreamFactory = false; + var callDisposer = false; + + final stream = Rx.using( + resourceFactory: resourceFactoryThrows, + streamFactory: (resource) { + calledStreamFactory = true; + return Rx.range(0, 3); + }, + disposer: (resource) { + callDisposer = true; + return disposer(resource); + }, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, false); + expect(calledStreamFactory, false); + expect(callDisposer, false); + }); + + test('$groupPrefix.disposer.throws', () async { + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.timer(0, resourceDuration), + disposer: disposerThrows, + ).listen(null); + + if (create == Create.async) { + await Future.delayed(resourceDuration * 1.2); + } + + await expectLater( + subscription.cancel(), + throwsException, + ); + }); + + test('$groupPrefix.streamFactory.throws', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => throw Exception(), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.streamFactory.errors', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.error(Exception()), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.cancel.delayed', () async { + const duration = Duration(milliseconds: 200); + + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, duration), + Stream.error(Exception()), + ]), + disposer: disposer, + ).listen( + null, + cancelOnError: false, + ); + + // ensure the stream has started + await Future.delayed(resourceDuration + duration ~/ 2); + await subscription.cancel(); + await Future.delayed(resourceDuration * 1.2); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.cancel.immediately', () async { + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, const Duration(milliseconds: 10)), + Stream.error(Exception()), + ]), + disposer: disposer, + ).listen( + expectAsync1((v) => expect(true, false), count: 0), + onError: expectAsync2( + (Object e, StackTrace stackTrace) => expect(true, false), + count: 0, + ), + onDone: expectAsync0(() => expect(true, false), count: 0), + ); + + await subscription.cancel(); + await Future.delayed(resourceDuration * 2); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.errors.continueOnError', () async { + Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, resourceDuration * 2), + Stream.error(Exception()) + ]), + disposer: disposer, + ).listen( + null, + onError: (Object e, StackTrace s) {}, + cancelOnError: false, + ); + + await Future.delayed(resourceDuration * 1.2); + expect(isResourceCreated, true); + expect(resource.isClosed, false); + }); + + test('$groupPrefix.errors.cancelOnError', () async { + Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.error(Exception()), + disposer: disposer, + ).listen( + null, + onError: (Object e, StackTrace s) {}, + cancelOnError: true, + ); + + await Future.delayed(resourceDuration * 1.2); + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.single.subscription', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.range(0, 3), + disposer: disposer, + ); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('$groupPrefix.asBroadcastStream', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.periodic( + const Duration(milliseconds: 50), + (i) => i, + ), + disposer: disposer, + ).asBroadcastStream(onCancel: (s) => s.cancel()); + + final s1 = stream.listen(null); + final s2 = stream.listen(null); + + // can reach here + expect(true, true); + + await Future.delayed(resourceDuration * 1.2); + await s1.cancel(); + await s2.cancel(); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.periodic( + const Duration(milliseconds: 20), + (i) => i, + ), + disposer: disposer, + ).listen( + expectAsync1( + (value) { + subscription.cancel(); + expect(value, 0); + }, + count: 1, + ), + ); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); + + test('$groupPrefix.disposer.order', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) { + final controller = StreamController(); + + controller.onListen = () { + controller.add(1); + controller.add(2); + controller.close(); + }; + + controller.onCancel = () async { + expect(resource.isClosed, false); + await Future.delayed(resourceDuration * 10); + expect(resource.isClosed, false); + }; + + return controller.stream; + }, + disposer: disposer, + ).take(1); + + await expectLater( + stream, + emitsInOrder([1, emitsDone]), + ); + }); + }); + } + } +} diff --git a/sandbox/reactivex/test/streams/value_connectable_stream_test.dart b/sandbox/reactivex/test/streams/value_connectable_stream_test.dart new file mode 100644 index 0000000..e27def1 --- /dev/null +++ b/sandbox/reactivex/test/streams/value_connectable_stream_test.dart @@ -0,0 +1,295 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('BehaviorConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = ValueConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + var count = 0; + const items = [1, 2, 3]; + final stream = ValueConnectableStream(Stream.fromIterable(items)); + + stream.connect(); + + expect(stream, emitsInOrder(items)); + stream.listen(expectAsync1((i) { + expect(stream.value, items[count]); + count++; + }, count: items.length)); + }); + + test('stops emitting after the connection is cancelled', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).publishValue(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('stops emitting after the last subscriber unsubscribes', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('keeps emitting with an active subscription', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(null); + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('multicasts a single-subscription stream', () async { + final stream = ValueConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('replays the latest item', () async { + final stream = ValueConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(3)); + }); + + test('replays the seeded item', () async { + final stream = + ValueConnectableStream.seeded(StreamController().stream, 3) + .autoConnect(); + + expect(stream, emitsInOrder(const [3])); + expect(stream, emitsInOrder(const [3])); + expect(stream, emitsInOrder(const [3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(3)); + }); + + test('replays the seeded null item', () async { + final stream = + ValueConnectableStream.seeded(StreamController().stream, null) + .autoConnect(); + + expect(stream, emitsInOrder(const [null])); + expect(stream, emitsInOrder(const [null])); + expect(stream, emitsInOrder(const [null])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(null)); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('transform Stream with initial value', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValueSeeded(0); + + expect(stream.value, 0); + expect(stream, emitsInOrder(const [0, 1, 2, 3])); + }); + + test('provides access to the latest value', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(expectAsync1((data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.value, 3); + } + }, count: items.length)); + }); + + test('provides access to the latest error', () async { + final source = StreamController(); + final stream = ValueConnectableStream(source.stream).autoConnect(); + + source.sink.add(1); + source.sink.add(2); + source.sink.add(3); + source.sink.addError(Exception('error')); + + stream.listen( + null, + onError: expectAsync1((Object error) { + expect(stream.valueOrNull, 3); + expect(stream.value, 3); + expect(stream.hasValue, isTrue); + + expect(stream.errorOrNull, error); + expect(stream.error, error); + expect(stream.hasError, isTrue); + }), + ); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publishValue() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareValue(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + } + + { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareValueSeeded(null); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + } + }); + + test('can close shareValue() stream', () async { + { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareValue() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + await expectLater(isCanceled.future, completes); + } + + { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareValueSeeded(false) + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + await expectLater(isCanceled.future, completes); + } + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + ValueConnectableStream stream() => Stream.value(1).publishValue(); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/sandbox/reactivex/test/streams/zip_test.dart b/sandbox/reactivex/test/streams/zip_test.dart new file mode 100644 index 0000000..feb7949 --- /dev/null +++ b/sandbox/reactivex/test/streams/zip_test.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.zip', () async { + expect( + Rx.zip([ + Stream.fromIterable(['A1', 'B1']), + Stream.fromIterable(['A2', 'B2', 'C2']), + ], (values) => values.first + values.last), + emitsInOrder(['A1A2', 'B1B2', emitsDone]), + ); + }); + + test('Rx.zip.empty', () { + expect(Rx.zipList([]), emitsDone); + }); + + test('Rx.zip.single', () { + expect( + Rx.zipList([Stream.value(1)]), + emitsInOrder([ + [1], + emitsDone + ]), + ); + }); + + test('Rx.zip.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.zipList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.zipList', () async { + expect( + Rx.zipList([ + Stream.fromIterable(['A1', 'B1']), + Stream.fromIterable(['A2', 'B2', 'C2']), + Stream.fromIterable(['A3', 'B3', 'C3']), + ]), + emitsInOrder([ + ['A1', 'A2', 'A3'], + ['B1', 'B2', 'B3'], + emitsDone + ]), + ); + }); + + test('Rx.zipBasics', () async { + const expectedOutput = [ + [0, 1, true], + [1, 2, false], + [2, 3, true], + [3, 4, false] + ]; + var count = 0; + + final testStream = StreamController() + ..add(true) + ..add(false) + ..add(true) + ..add(false) + ..add(true) + ..close(); // ignore: unawaited_futures + + final stream = Rx.zip3( + Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(4), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6, 7, 8, 9]), + testStream.stream, + (int a, int b, bool c) => [a, b, c]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + for (var i = 0, len = result.length; i < len; i++) { + expect(result[i], expectedOutput[count][i]); + } + + count++; + }, count: expectedOutput.length)); + }); + + test('Rx.zipTwo', () async { + const expected = [1, 2]; + + // A purposely emits 2 items, b only 1 + final a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = Rx.zip2(a, b, (int first, int second) => [first, second]); + + // Explicitly adding count: 1. It's important here, and tests the difference + // between zip and combineLatest. If this was combineLatest, the count would + // be two, and a second List would be emitted. + stream.listen(expectAsync1((result) { + expect(result, expected); + }, count: 1)); + }); + + test('Rx.zip3', () async { + // Verify the ability to pass through various types with safety + const expected = [1, '2', 3.0]; + + final a = Stream.value(1), b = Stream.value('2'), c = Stream.value(3.0); + + final stream = Rx.zip3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip4', () async { + const expected = [1, 2, 3, 4]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.zip4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip5', () async { + const expected = [1, 2, 3, 4, 5]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.zip5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip6', () async { + const expected = [1, 2, 3, 4, 5, 6]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.zip6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip7', () async { + const expected = [1, 2, 3, 4, 5, 6, 7]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.zip7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip8', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.zip8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip9', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.zip9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip.single.subscription', () async { + final stream = + Rx.zip2(Stream.value(1), Stream.value(1), (int a, int b) => a + b); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.zip.asBroadcastStream', () async { + final testStream = StreamController() + ..add(true) + ..add(false) + ..add(true) + ..add(false) + ..add(true) + ..close(); // ignore: unawaited_futures + + final stream = Rx.zip3( + Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(4), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6, 7, 8, 9]), + testStream.stream, + (int a, int b, bool c) => [a, b, c]).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.zip.error.shouldThrowA', () async { + final streamWithError = Rx.zip2( + Stream.value(1), + Stream.value(2), + (int a, int b) => throw Exception(), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + /*test('Rx.zip.error.shouldThrowB', () { + expect( + () => Rx.zip2( + Stream.value(1), null, (int a, _) => null), + throwsArgumentError); + }); + + test('Rx.zip.error.shouldThrowC', () { + expect(() => ZipStream(null, () {}), throwsArgumentError); + }); + + test('Rx.zip.error.shouldThrowD', () { + expect(() => ZipStream(>[], () {}), + throwsArgumentError); + });*/ + + test('Rx.zip.pause.resume.A', () async { + late StreamSubscription subscription; + final stream = + Rx.zip2(Stream.value(1), Stream.value(2), (int a, int b) => a + b); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 3); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.zip.pause.resume.B', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription> subscription; + subscription = + Rx.zip3(first, second, last, (num a, num b, num c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 1); + expect(value.elementAt(1), 5); + expect(value.elementAt(2), 9); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); +} diff --git a/sandbox/reactivex/test/subject/behavior_subject_test.dart b/sandbox/reactivex/test/subject/behavior_subject_test.dart new file mode 100644 index 0000000..5f51cb3 --- /dev/null +++ b/sandbox/reactivex/test/subject/behavior_subject_test.dart @@ -0,0 +1,1475 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + final throwsValueStreamError = throwsA(isA()); + + group('BehaviorSubject', () { + test('emits the most recently emitted item to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('emits the most recently emitted null item to every subscriber', + () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(null); + + seeded.add(1); + seeded.add(2); + seeded.add(null); + + await expectLater(unseeded.stream, emits(isNull)); + await expectLater(unseeded.stream, emits(isNull)); + await expectLater(unseeded.stream, emits(isNull)); + + await expectLater(seeded.stream, emits(isNull)); + await expectLater(seeded.stream, emits(isNull)); + await expectLater(seeded.stream, emits(isNull)); + }); + + test( + 'emits the most recently emitted item to every subscriber that subscribe to the subject directly', + () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + await expectLater(unseeded, emits(3)); + await expectLater(unseeded, emits(3)); + await expectLater(unseeded, emits(3)); + + await expectLater(seeded, emits(3)); + await expectLater(seeded, emits(3)); + await expectLater(seeded, emits(3)); + }); + + test('emits errors to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + unseeded.addError(Exception('oh noes!')); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + seeded.addError(Exception('oh noes!')); + + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(unseeded.stream, emitsError(isException)); + + await expectLater(seeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + }); + + test('emits event after error to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.addError(Exception('oh noes!')); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.addError(Exception('oh noes!')); + seeded.add(3); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('emits errors to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + final exception = Exception('oh noes!'); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + unseeded.addError(exception); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + seeded.addError(exception); + + expect(unseeded.value, 3); + expect(unseeded.valueOrNull, 3); + expect(unseeded.hasValue, true); + + expect(unseeded.error, exception); + expect(unseeded.errorOrNull, exception); + expect(unseeded.hasError, true); + + await expectLater(unseeded, emitsError(exception)); + await expectLater(unseeded, emitsError(exception)); + await expectLater(unseeded, emitsError(exception)); + + expect(seeded.value, 3); + expect(seeded.valueOrNull, 3); + expect(seeded.hasValue, true); + + expect(seeded.error, exception); + expect(seeded.errorOrNull, exception); + expect(seeded.hasError, true); + + await expectLater(seeded, emitsError(exception)); + await expectLater(seeded, emitsError(exception)); + await expectLater(seeded, emitsError(exception)); + }); + + test('can synchronously get the latest value', () { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + expect(unseeded.value, 3); + expect(unseeded.valueOrNull, 3); + expect(unseeded.hasValue, true); + + expect(seeded.value, 3); + expect(seeded.valueOrNull, 3); + expect(seeded.hasValue, true); + }); + + test('can synchronously get the latest null value', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(null); + + seeded.add(1); + seeded.add(2); + seeded.add(null); + + expect(unseeded.value, isNull); + expect(unseeded.valueOrNull, isNull); + expect(unseeded.hasValue, true); + + expect(seeded.value, isNull); + expect(seeded.valueOrNull, isNull); + expect(seeded.hasValue, true); + }); + + test('emits the seed item if no new items have been emitted', () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + await expectLater(subject.stream, emits(1)); + await expectLater(subject.stream, emits(1)); + await expectLater(subject.stream, emits(1)); + }); + + test('emits the null seed item if no new items have been emitted', + () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + await expectLater(subject.stream, emits(isNull)); + await expectLater(subject.stream, emits(isNull)); + await expectLater(subject.stream, emits(isNull)); + }); + + test('can synchronously get the initial value', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.value, 1); + expect(subject.valueOrNull, 1); + expect(subject.hasValue, true); + }); + + test('can synchronously get the initial null value', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.value, null); + expect(subject.valueOrNull, null); + expect(subject.hasValue, true); + }); + + test('initial value is null when no value has been emitted', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(() => subject.value, throwsValueStreamError); + expect(subject.valueOrNull, null); + expect(subject.hasValue, false); + }); + + test('emits done event to listeners when the subject is closed', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await expectLater(unseeded.isClosed, isFalse); + await expectLater(seeded.isClosed, isFalse); + + unseeded.add(1); + scheduleMicrotask(() => unseeded.close()); + + seeded.add(1); + scheduleMicrotask(() => seeded.close()); + + await expectLater(unseeded.stream, emitsInOrder([1, emitsDone])); + await expectLater(unseeded.isClosed, isTrue); + + await expectLater(seeded.stream, emitsInOrder([1, emitsDone])); + await expectLater(seeded.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + scheduleMicrotask(() => unseeded.addError(Exception())); + scheduleMicrotask(() => seeded.addError(Exception())); + + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + }); + + test('replays the previously emitted items from addStream', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await unseeded.addStream(Stream.fromIterable(const [1, 2, 3])); + await seeded.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('replays the previously emitted errors from addStream', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await unseeded.addStream(Stream.error('error'), + cancelOnError: false); + await seeded.addStream(Stream.error('error'), cancelOnError: false); + + await expectLater(unseeded.stream, emitsError('error')); + await expectLater(unseeded.stream, emitsError('error')); + }); + + test('allows items to be added once addStream is complete', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + subject.add(3); + + await expectLater(subject.stream, emits(3)); + }); + + test('allows items to be added once addStream completes with an error', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = BehaviorSubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + // ignore: close_sinks + final subject = BehaviorSubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = BehaviorSubject(); + + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + final stream = subject.stream; + + await expectLater(stream, emits(1)); + await expectLater(stream, emits(1)); + }); + + test('always returns the same stream', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.sink.add(1); + + expect(subject.value, 1); + + subject.sink.add(2); + subject.sink.add(3); + + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + }); + + test('setter `value=` has same behavior as adding to Subject', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.value = 1; + + expect(subject.value, 1); + + subject.value = 2; + subject.value = 3; + + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + }); + + test('is always treated as a broadcast Stream', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('hasValue returns false for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasValue, isFalse); + }); + + test('hasValue returns true for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasValue, isTrue); + }); + + test('hasValue returns true for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasValue, isTrue); + }); + + test('hasValue returns true for an unseeded subject after an emission', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + + expect(subject.hasValue, isTrue); + }); + + test('hasError returns false for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for an unseeded subject after an emission', + () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns true for an unseeded subject after addError', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + subject.addError('error'); + + expect(subject.hasError, isTrue); + }); + + test('hasError returns true for a seeded subject after addError', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + subject.addError('error'); + + expect(subject.hasError, isTrue); + }); + + test('error returns null for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('error returns null for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('error returns null for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('can synchronously get the latest error', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + expect(unseeded.hasError, isFalse); + expect(unseeded.errorOrNull, isNull); + expect(() => unseeded.error, throwsValueStreamError); + + unseeded.addError(Exception('oh noes!')); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + expect(seeded.hasError, isFalse); + expect(seeded.errorOrNull, isNull); + expect(() => seeded.error, throwsValueStreamError); + + seeded.addError(Exception('oh noes!')); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + }); + + test('emits event after error to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.addError(Exception('oh noes!')); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + unseeded.add(3); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + + seeded.add(1); + seeded.add(2); + seeded.addError(Exception('oh noes!')); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + seeded.add(3); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + }); + + test( + 'issue/350: emits duplicate values when listening multiple times and starting with an Error', + () async { + final subject = BehaviorSubject(); + + subject.addError('error'); + + await subject.close(); + + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + }); + + test('issue/419: sync behavior', () async { + final subject = BehaviorSubject.seeded(1, sync: true); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + expect(mappedStream.value, equals(1)); + + await subject.close(); + }, skip: true); + + test('issue/419: sync throughput', () async { + final subject = BehaviorSubject.seeded(1, sync: true); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + subject.add(2); + + expect(mappedStream.value, equals(2)); + + await subject.close(); + }, skip: true); + + test('issue/419: async behavior', () async { + final subject = BehaviorSubject.seeded(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(1))); + + expect(() => mappedStream.value, throwsValueStreamError); + expect(mappedStream.valueOrNull, isNull); + expect(mappedStream.hasValue, false); + + await subject.close(); + }); + + test('issue/419: async throughput', () async { + final subject = BehaviorSubject.seeded(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(2))); + + subject.add(2); + + expect(() => mappedStream.value, throwsValueStreamError); + expect(mappedStream.valueOrNull, isNull); + expect(mappedStream.hasValue, false); + + await subject.close(); + }); + + test('issue/477: get first after cancelled', () async { + final a = BehaviorSubject.seeded('a'); + final bug = a.switchMap((v) => BehaviorSubject.seeded('b')); + await bug.listen(null).cancel(); + expect(await bug.first, 'b'); + }); + + test('issue/477: get first multiple times', () async { + final a = BehaviorSubject.seeded('a'); + final bug = a.switchMap((_) => BehaviorSubject.seeded('b')); + bug.listen(null); + expect(await bug.first, 'b'); + expect(await bug.first, 'b'); + }); + + test('issue/478: get first multiple times', () async { + final a = BehaviorSubject.seeded('a'); + final b = BehaviorSubject.seeded('b'); + final bug = + Rx.combineLatest2(a, b, (String _a, String _b) => 'ab').shareValue(); + expect(await bug.first, 'ab'); + expect(await bug.first, 'ab'); + }); + + test('angel3_reactivex #477/#500 - a', () async { + final a = BehaviorSubject.seeded('a') + .switchMap((_) => BehaviorSubject.seeded('a')) + ..listen(print); + await pumpEventQueue(); + expect(await a.first, 'a'); + }); + + test('angel3_reactivex #477/#500 - b', () async { + final b = BehaviorSubject.seeded('b') + .map((_) => 'b') + .switchMap((_) => BehaviorSubject.seeded('b')) + ..listen(print); + await pumpEventQueue(); + expect(await b.first, 'b'); + }); + + test('issue/587', () async { + final source = BehaviorSubject.seeded('source'); + final switched = + source.switchMap((value) => BehaviorSubject.seeded('switched')); + var i = 0; + switched.listen((_) => i++); + expect(await switched.first, 'switched'); + expect(i, 1); + expect(await switched.first, 'switched'); + expect(i, 1); + }); + + test('do not update latest value after closed', () { + final seeded = BehaviorSubject.seeded(0); + final unseeded = BehaviorSubject(); + + seeded.add(1); + unseeded.add(1); + + expect(seeded.value, 1); + expect(unseeded.value, 1); + + seeded.close(); + unseeded.close(); + + expect(() => seeded.add(2), throwsStateError); + expect(() => unseeded.add(2), throwsStateError); + expect(() => seeded.addError(Exception()), throwsStateError); + expect(() => unseeded.addError(Exception()), throwsStateError); + + expect(seeded.value, 1); + expect(unseeded.value, 1); + }); + + group('override built-in', () { + test('where', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.where((event) => event.isOdd); + expect(stream, emitsInOrder([1, 3])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.where((event) => event.isOdd); + expect(stream, emitsInOrder([1, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('map', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var mapped = behaviorSubject.map((event) => event + 1); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var mapped = behaviorSubject.map((event) => event + 1); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('asyncMap', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var mapped = + behaviorSubject.asyncMap((event) => Future.value(event + 1)); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var mapped = + behaviorSubject.asyncMap((event) => Future.value(event + 1)); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('asyncExpand', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = + behaviorSubject.asyncExpand((event) => Stream.value(event + 1)); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = + behaviorSubject.asyncExpand((event) => Stream.value(event + 1)); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('handleError', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.handleError( + expectAsync1( + (dynamic e) => expect(e, isException), + count: 1, + ), + ); + + expect( + stream, + emitsInOrder([1, 2]), + ); + + behaviorSubject.addError(Exception()); + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.handleError( + expectAsync1( + (dynamic e) => expect(e, isException), + count: 1, + ), + ); + + expect( + stream, + emitsInOrder([1, 2]), + ); + + behaviorSubject.add(1); + behaviorSubject.addError(Exception()); + behaviorSubject.add(2); + } + }); + + test('expand', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.expand((event) => [event + 1]); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.expand((event) => [event + 1]); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('transform', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.transform( + IntervalStreamTransformer(const Duration(milliseconds: 100))); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.transform( + IntervalStreamTransformer(const Duration(milliseconds: 100))); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('cast', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.cast(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.cast(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('take', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.take(2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.take(2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('takeWhile', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.takeWhile((element) => element <= 2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.takeWhile((element) => element <= 2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('skip', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.skip(2); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.skip(2); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + + test('skipWhile', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.skipWhile((element) => element < 3); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.skipWhile((element) => element < 3); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + + test('distinct', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.distinct(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.distinct(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(2); + } + }); + + test('timeout', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject + .interval(const Duration(milliseconds: 100)) + .timeout( + const Duration(milliseconds: 70), + onTimeout: expectAsync1( + (EventSink sink) {}, + count: 4, + ), + ); + + expect(stream, emitsInOrder([1, 2, 3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject + .interval(const Duration(milliseconds: 100)) + .timeout( + const Duration(milliseconds: 70), + onTimeout: expectAsync1( + (EventSink sink) {}, + count: 4, + ), + ); + + expect(stream, emitsInOrder([1, 2, 3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + }); + + test('stream returns a read-only stream', () async { + final subject = BehaviorSubject()..add(1); + + // streams returned by BehaviorSubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + expect( + subject.stream, + isA>().having( + (v) => v.value, + 'BehaviorSubject.stream.value', + 1, + ), + ); + + // BehaviorSubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + await expectLater(stream, emitsInOrder([1])); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + + group('lastEventOrNull', () { + test('empty subject', () { + final s = BehaviorSubject(); + expect(s.lastEventOrNull, isNull); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect(s.stream.lastEventOrNull, isNull); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isFalse); + }); + + test('subject with value', () { + final s = BehaviorSubject.seeded(42); + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('subject with error', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + + test('add error and then value', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + s.add(42); + + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('add value and then error', () { + final s = BehaviorSubject(); + s.add(42); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + + test('add value and then close', () async { + final s = BehaviorSubject(); + s.add(42); + await s.close(); + + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('add error and then close', () async { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + await s.close(); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + }); + + group('errorAndStackTraceOrNull', () { + test('empty subject', () { + final s = BehaviorSubject(); + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('seeded subject', () { + final s = BehaviorSubject.seeded(42); + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('subject with error and stack trace', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, StackTrace.empty), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, StackTrace.empty), + ); + }); + + test('subject with error', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + }); + + test('seeded subject and close', () { + final s = BehaviorSubject.seeded(42)..close(); + + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('error and close', () { + final s = BehaviorSubject(); + final exception = Exception(); + s + ..addError(exception) + ..close(); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + }); + }); + }); +} diff --git a/sandbox/reactivex/test/subject/publish_subject_test.dart b/sandbox/reactivex/test/subject/publish_subject_test.dart new file mode 100644 index 0000000..1561109 --- /dev/null +++ b/sandbox/reactivex/test/subject/publish_subject_test.dart @@ -0,0 +1,323 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:angel3_reactivex/subjects.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('PublishSubject', () { + test('emits items to every subscriber', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.add(1); + subject.add(2); + subject.add(3); + subject.close(); + }); + + await expectLater( + subject.stream, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test( + 'emits items to every subscriber that subscribe directly to the Subject', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.add(1); + subject.add(2); + subject.add(3); + subject.close(); + }); + + await expectLater(subject, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test('emits done event to listeners when the subject is closed', () async { + final subject = PublishSubject(); + + await expectLater(subject.isClosed, isFalse); + + scheduleMicrotask(() => subject.add(1)); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.stream, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test( + 'emits done event to listeners when the subject is closed (listen directly on Subject)', + () async { + final subject = PublishSubject(); + + await expectLater(subject.isClosed, isFalse); + + scheduleMicrotask(() => subject.add(1)); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject.stream, emitsError(isException)); + }); + + test('emits error events to subscribers (listen directly on Subject)', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject, emitsError(isException)); + }); + + test('emits the items from addStream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask( + () => subject.addStream(Stream.fromIterable(const [1, 2, 3]))); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream is complete', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + scheduleMicrotask(() => subject.add(3)); + + await expectLater(subject.stream, emits(3)); + }); + + test('allows items to be added once addStream completes with an error', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = PublishSubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + // ignore: close_sinks + final subject = PublishSubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + // ignore: close_sinks + final subject = PublishSubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + // ignore: close_sinks + final subject = PublishSubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + // ignore: close_sinks + final subject = PublishSubject(); + final stream = subject.stream; + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emits(1)); + + scheduleMicrotask(() => subject.add(2)); + await expectLater(stream, emits(2)); + }); + + test('always returns the same stream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.sink.add(1); + subject.sink.add(2); + subject.sink.add(3); + subject.sink.close(); + }); + + await expectLater( + subject.stream, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test('is always treated as a broadcast Stream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('stream returns a read-only stream', () async { + final subject = PublishSubject(); + + // streams returned by PublishSubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + // PublishSubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emitsInOrder([1])); + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + }); +} diff --git a/sandbox/reactivex/test/subject/replay_subject_test.dart b/sandbox/reactivex/test/subject/replay_subject_test.dart new file mode 100644 index 0000000..1d9e949 --- /dev/null +++ b/sandbox/reactivex/test/subject/replay_subject_test.dart @@ -0,0 +1,478 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +// ignore_for_file: close_sinks + +void main() { + group('ReplaySubject', () { + test('replays the previously emitted items to every subscriber', () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test( + 'replays the previously emitted items to every subscriber, includes null', + () async { + final subject = ReplaySubject(); + + subject.add(null); + subject.add(1); + subject.add(2); + subject.add(3); + subject.add(null); + + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + }); + + test('replays the previously emitted errors to every subscriber', () async { + final subject = ReplaySubject(); + + subject.addError(Exception()); + subject.addError(Exception()); + subject.addError(Exception()); + + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + }); + + test( + 'replays the previously emitted items to every subscriber that directly subscribes to the Subject', + () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + }); + + test( + 'replays the previously emitted items and errors to every subscriber that directly subscribes to the Subject', + () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.addError(Exception()); + subject.addError(Exception()); + subject.add(2); + + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + }); + + test('synchronously get the previous items', () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject.values, const [1, 2, 3]); + }); + + test('synchronously get the previous errors', () { + final subject = ReplaySubject(); + final e1 = Exception(), e2 = Exception(), e3 = Exception(); + final stackTrace = StackTrace.fromString('#'); + + subject.addError(e1); + subject.addError(e2, stackTrace); + subject.addError(e3); + + expect( + subject.errors, + containsAllInOrder([e1, e2, e3]), + ); + expect( + subject.stackTraces, + containsAllInOrder([null, stackTrace, null]), + ); + }); + + test('replays the most recently emitted items up to a max size', () async { + final subject = ReplaySubject(maxSize: 2); + + subject.add(1); // Should be dropped + subject.add(2); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + }); + + test('emits done event to listeners when the subject is closed', () async { + final subject = ReplaySubject(); + + await expectLater(subject.isClosed, isFalse); + + subject.add(1); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.stream, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + final subject = ReplaySubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject.stream, emitsError(isException)); + }); + + test('replays the previously emitted items from addStream', () async { + final subject = ReplaySubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream is complete', () async { + final subject = ReplaySubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream completes with an error', + () async { + final subject = ReplaySubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + + final subject = ReplaySubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + + final subject = ReplaySubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + + final subject = ReplaySubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + + final subject = ReplaySubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + final subject = ReplaySubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + final subject = ReplaySubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + final subject = ReplaySubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + final subject = ReplaySubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = ReplaySubject(); + + scheduleMicrotask(subject.close); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + final subject = ReplaySubject(); + final stream = subject.stream; + + subject.add(1); + subject.add(2); + + await expectLater(stream, emitsInOrder(const [1, 2])); + await expectLater(stream, emitsInOrder(const [1, 2])); + }); + + test('always returns the same stream', () async { + final subject = ReplaySubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + final subject = ReplaySubject(); + + subject.sink.add(1); + subject.sink.add(2); + subject.sink.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('is always treated as a broadcast Stream', () async { + final subject = ReplaySubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('issue/419: sync behavior', () async { + final subject = ReplaySubject(sync: true)..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + expect(mappedStream.value, equals(1)); + + await subject.close(); + }, skip: true); + + test('issue/419: sync throughput', () async { + final subject = ReplaySubject(sync: true)..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + subject.add(2); + + expect(mappedStream.value, equals(2)); + + await subject.close(); + }, skip: true); + + test('issue/419: async behavior', () async { + final subject = ReplaySubject()..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(1))); + + expect(mappedStream.valueOrNull, isNull); + + await subject.close(); + }); + + test('issue/419: async throughput', () async { + final subject = ReplaySubject()..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(2))); + + subject.add(2); + + expect(mappedStream.valueOrNull, isNull); + + await subject.close(); + }); + + test('do not update buffer after closed', () { + final subject = ReplaySubject(); + + subject.add(1); + expect(subject.values, [1]); + + subject.close(); + + expect(() => subject.add(2), throwsStateError); + expect(() => subject.addError(Exception()), throwsStateError); + expect(subject.values, [1]); + }); + + test('stream returns a read-only stream', () async { + final subject = ReplaySubject()..add(1); + + // streams returned by ReplaySubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + expect( + subject.stream, + isA>().having( + (v) => v.values, + 'ReplaySubject.stream.values', + [1], + ), + ); + + // ReplaySubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + await expectLater(stream, emitsInOrder([1])); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/buffer_count_test.dart b/sandbox/reactivex/test/transformers/backpressure/buffer_count_test.dart new file mode 100644 index 0000000..5e05ade --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/buffer_count_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.bufferCount.noStartBufferEvery', () async { + await expectLater( + Rx.range(1, 4).bufferCount(2), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferCount.noStartBufferEvery.includesEventOnClose', () async { + await expectLater( + Rx.range(1, 5).bufferCount(2), + emitsInOrder([ + const [1, 2], + const [3, 4], + const [5], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count2startBufferEvery1', () async { + await expectLater( + Rx.range(1, 4).bufferCount(2, 1), + emitsInOrder([ + const [1, 2], + const [2, 3], + const [3, 4], + const [4], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count3startBufferEvery2', () async { + await expectLater( + Rx.range(1, 8).bufferCount(3, 2), + emitsInOrder([ + const [1, 2, 3], + const [3, 4, 5], + const [5, 6, 7], + const [7, 8], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count3startBufferEvery4', () async { + await expectLater( + Rx.range(1, 8).bufferCount(3, 4), + emitsInOrder([ + const [1, 2, 3], + const [5, 6, 7], + emitsDone + ])); + }); + + test('Rx.bufferCount.reusable', () async { + final transformer = BufferCountStreamTransformer(2); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferCount.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .bufferCount(2); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.bufferCount.error.shouldThrowA', () async { + await expectLater(Stream.error(Exception()).bufferCount(2), + emitsError(isException)); + }); + + test( + 'Rx.bufferCount.shouldThrow.invalidCount.negative', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).bufferCount(-1), + throwsArgumentError); + }, + ); + + test('Rx.bufferCount.startBufferEvery.shouldThrow.invalidStartBufferEvery', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).bufferCount(2, -1), + throwsArgumentError); + }); + + test('Rx.bufferCount.nullable', () { + nullableTest>( + (s) => s.bufferCount(1), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/buffer_test.dart b/sandbox/reactivex/test/transformers/backpressure/buffer_test.dart new file mode 100644 index 0000000..095d657 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/buffer_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream getStream(int n) async* { + var k = 0; + + while (k < n) { + await Future.delayed(const Duration(milliseconds: 100)); + + yield k++; + } +} + +void main() { + test('Rx.buffer', () async { + await expectLater( + getStream(4).buffer( + Stream.periodic(const Duration(milliseconds: 160)).take(3)), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.buffer.sampleBeforeEvent.shouldEmit', () async { + await expectLater( + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => 'end')).startWith('start').buffer( + Stream.periodic(const Duration(milliseconds: 40)).take(10)), + emitsInOrder([ + const ['start'], // after 40ms + const [], // 80ms + const [], // 120ms + const [], // 160ms + const ['end'], // done + emitsDone + ])); + }); + + test('Rx.buffer.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .buffer(Stream.periodic(const Duration(seconds: 3))) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.buffer.reusable', () async { + final transformer = BufferStreamTransformer((_) => + Stream.periodic(const Duration(milliseconds: 160)) + .take(3) + .asBroadcastStream()); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.buffer.asBroadcastStream', () async { + final stream = getStream(4).asBroadcastStream().buffer( + Stream.periodic(const Duration(milliseconds: 160)) + .take(10) + .asBroadcastStream()); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.buffer.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .buffer(Stream.periodic(const Duration(milliseconds: 160))), + emitsError(isException)); + }); + + test('Rx.buffer.nullable', () { + nullableTest>( + (s) => s.buffer(Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/buffer_test_test.dart b/sandbox/reactivex/test/transformers/backpressure/buffer_test_test.dart new file mode 100644 index 0000000..0d08c4c --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/buffer_test_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.bufferTest', () async { + await expectLater( + Rx.range(1, 4).bufferTest((i) => i % 2 == 0), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferTest.reusable', () async { + final transformer = BufferTestStreamTransformer((i) => i % 2 == 0); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferTest.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .bufferTest((i) => i % 2 == 0); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater(stream, emitsDone); + }); + + test('Rx.bufferTest.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).bufferTest((i) => i % 2 == 0), + emitsError(isException)); + }); + + test('Rx.bufferTest.nullable', () { + nullableTest>( + (s) => s.bufferTest((i) => true), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/buffer_time_test.dart b/sandbox/reactivex/test/transformers/backpressure/buffer_time_test.dart new file mode 100644 index 0000000..8feea7d --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/buffer_time_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +/// yield immediately, then every 100ms +Stream getStream(int n) async* { + var k = 1; + + yield 0; + + while (k < n) { + yield await Future.delayed(const Duration(milliseconds: 100)) + .then((_) => k++); + } +} + +void main() { + test('Rx.bufferTime', () async { + await expectLater( + getStream(4).bufferTime(const Duration(milliseconds: 160)), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.bufferTime.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream.bufferTime(const Duration(seconds: 3)).take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.bufferTime.reusable', () async { + final transformer = BufferStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 160))); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + }); + + test('Rx.bufferTime.asBroadcastStream', () async { + final stream = getStream(4) + .asBroadcastStream() + .bufferTime(const Duration(milliseconds: 160)); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.bufferTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .bufferTime(const Duration(milliseconds: 160)), + emitsError(isException)); + }); + + test('Rx.bufferTime.nullable', () { + nullableTest>( + (s) => s.bufferTime(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/debounce_test.dart b/sandbox/reactivex/test/transformers/backpressure/debounce_test.dart new file mode 100644 index 0000000..e8bc075 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/debounce_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.debounce', () async { + await expectLater( + _getStream().debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounce.dynamicWindow', () async { + // Given the input [1, 2, 3, 4] + // debounce 200ms on [1, 2, 4] + // debounce 0ms on [3] + // yields [3, 4, done] + await expectLater( + _getStream().debounce((value) => value == 3 + ? Stream.value(true) + : Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([3, 4, emitsDone])); + }); + + test('Rx.debounce.reusable', () async { + final transformer = DebounceStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 200))); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounce.asBroadcastStream', () async { + final future = _getStream() + .asBroadcastStream() + .debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))) + .drain(); + + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.debounce.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsError(isException)); + }); + + test('Rx.debounce.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3]) + .debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater(controller.stream, emitsInOrder([3, emitsDone])); + }); + + test('Rx.debounce.emits.last.item.immediately', () async { + final emissions = []; + final stopwatch = Stopwatch(); + final stream = Stream.fromIterable(const [1, 2, 3]).debounce((_) => + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))); + late StreamSubscription subscription; + + stopwatch.start(); + + subscription = stream.listen( + expectAsync1((val) { + emissions.add(val); + }, count: 1), onDone: expectAsync0(() { + stopwatch.stop(); + + expect(emissions, const [3]); + + // We debounce for 100 seconds. To ensure we aren't waiting that long to + // emit the last item after the base stream completes, we expect the + // last value to be emitted to be much shorter than that. + expect(stopwatch.elapsedMilliseconds < 500, isTrue); + + subscription.cancel(); + })); + }, timeout: Timeout(Duration(seconds: 3))); + + test( + 'Rx.debounce.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.debounce.last.event.can.be.null', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, null]).debounce((_) => + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([null, emitsDone])); + }); + + test('Rx.debounce.nullable', () { + nullableTest( + (s) => s.debounce((_) => Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/debounce_time_test.dart b/sandbox/reactivex/test/transformers/backpressure/debounce_time_test.dart new file mode 100644 index 0000000..8501763 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/debounce_time_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.debounceTime', () async { + await expectLater( + _getStream().debounceTime(const Duration(milliseconds: 200)), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounceTime.reusable', () async { + final transformer = DebounceStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 200))); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounceTime.asBroadcastStream', () async { + final future = _getStream() + .asBroadcastStream() + .debounceTime(const Duration(milliseconds: 200)) + .drain(); + + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.debounceTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .debounceTime(const Duration(milliseconds: 200)), + emitsError(isException)); + }); + + test('Rx.debounceTime.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3]) + .debounceTime(Duration(milliseconds: 100)) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater(controller.stream, emitsInOrder([3, emitsDone])); + }); + + test('Rx.debounceTime.emits.last.item.immediately', () async { + final emissions = []; + final stopwatch = Stopwatch(); + final stream = Stream.fromIterable(const [1, 2, 3]) + .debounceTime(Duration(seconds: 100)); + late StreamSubscription subscription; + + stopwatch.start(); + + subscription = stream.listen( + expectAsync1((val) { + emissions.add(val); + }, count: 1), onDone: expectAsync0(() { + stopwatch.stop(); + + expect(emissions, const [3]); + + // We debounce for 100 seconds. To ensure we aren't waiting that long to + // emit the last item after the base stream completes, we expect the + // last value to be emitted to be much shorter than that. + expect(stopwatch.elapsedMilliseconds < 500, isTrue); + + subscription.cancel(); + })); + }, timeout: Timeout(Duration(seconds: 3))); + + test( + 'Rx.debounceTime.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).debounceTime(Duration(seconds: 10)); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.debounceTime.last.event.can.be.null', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, null]) + .debounceTime(const Duration(milliseconds: 200)), + emitsInOrder([null, emitsDone])); + }); + + test('Rx.debounceTime.nullable', () { + nullableTest( + (s) => s.debounceTime(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/pairwise_test.dart b/sandbox/reactivex/test/transformers/backpressure/pairwise_test.dart new file mode 100644 index 0000000..da89fa0 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/pairwise_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.pairwise', () async { + const expectedOutput = [ + [1, 2], + [2, 3], + [3, 4] + ]; + var count = 0; + + final stream = Rx.range(1, 4).pairwise(); + + stream.listen( + expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length), + onError: expectAsync2((Object e, StackTrace s) {}, count: 0), + onDone: expectAsync0(() {}, count: 1), + ); + }); + + test('Rx.pairwise.empty', () { + expect(Stream.empty().pairwise(), emitsDone); + }); + + test('Rx.pairwise.single', () { + expect(Stream.value(1).pairwise(), emitsDone); + }); + + test('Rx.pairwise.compatible', () { + expect( + Stream.fromIterable([1, 2]).pairwise(), + isA>>(), + ); + + Stream> s = Stream.fromIterable([1, 2]).pairwise(); + expect( + s, + emitsInOrder([ + [1, 2], + emitsDone + ]), + ); + }); + + test('Rx.pairwise.asBroadcastStream', () async { + final stream = + Stream.fromIterable(const [1, 2, 3, 4]).asBroadcastStream().pairwise(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.pairwise.error.shouldThrow.onError', () async { + final streamWithError = Stream.error(Exception()).pairwise(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.pairwise.nullable', () { + nullableTest>( + (s) => s.pairwise(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/sample_test.dart b/sandbox/reactivex/test/transformers/backpressure/sample_test.dart new file mode 100644 index 0000000..b0137d3 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/sample_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() => + Stream.periodic(const Duration(milliseconds: 20), (count) => count) + .take(5); + +Stream _getSampleStream() => + Stream.periodic(const Duration(milliseconds: 35), (count) => count) + .take(10); + +void main() { + test('Rx.sample', () async { + final stream = _getStream().sample(_getSampleStream()); + + await expectLater(stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sample.reusable', () async { + final transformer = SampleStreamTransformer( + (_) => _getSampleStream().asBroadcastStream()); + final streamA = _getStream().transform(transformer); + final streamB = _getStream().transform(transformer); + + await expectLater(streamA, emitsInOrder([1, 3, 4, emitsDone])); + await expectLater(streamB, emitsInOrder([1, 3, 4, emitsDone])); + }, skip: true); + + test('Rx.sample.onDone', () async { + final stream = Stream.value(1).sample(Stream.empty()); + + await expectLater(stream, emits(1)); + }); + + test('Rx.sample.shouldClose', () async { + final controller = StreamController(); + + controller.stream + .sample(Stream.empty()) // should trigger onDone + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + + controller.add(0); + controller.add(1); + controller.add(2); + controller.add(3); + + scheduleMicrotask(controller.close); + }); + + test('Rx.sample.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .sample(_getSampleStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.sample.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).sample(_getSampleStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sample.error.shouldThrowB', () async { + final streamWithError = Stream.value(1) + .sample(Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sample.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = _getStream() + .sample(_getSampleStream()) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 3, 4, emitsDone])); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.sample.nullable', () { + nullableTest( + (s) => s.sample(_getSampleStream()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/sample_time_test.dart b/sandbox/reactivex/test/transformers/backpressure/sample_time_test.dart new file mode 100644 index 0000000..f6c0386 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/sample_time_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() => + Stream.periodic(const Duration(milliseconds: 20), (count) => count) + .take(5); + +void main() { + test('Rx.sampleTime', () async { + final stream = _getStream().sampleTime(const Duration(milliseconds: 35)); + + await expectLater(stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sampleTime.reusable', () async { + final transformer = SampleStreamTransformer((_) => + TimerStream(true, const Duration(milliseconds: 35)) + .asBroadcastStream()); + + await expectLater( + _getStream().transform(transformer).drain(), + completes, + ); + await expectLater( + _getStream().transform(transformer).drain(), + completes, + ); + }); + + test('Rx.sampleTime.onDone', () async { + final stream = Stream.value(1).sampleTime(const Duration(seconds: 1)); + + await expectLater(stream, emits(1)); + }); + + test('Rx.sampleTime.shouldClose', () async { + final controller = StreamController(); + + controller.stream + .sampleTime(const Duration(seconds: 1)) // should trigger onDone + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + + controller.add(0); + controller.add(1); + controller.add(2); + controller.add(3); + + scheduleMicrotask(controller.close); + }); + + test('Rx.sampleTime.asBroadcastStream', () async { + final stream = _getStream() + .sampleTime(const Duration(milliseconds: 35)) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.sampleTime.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .sampleTime(const Duration(milliseconds: 35)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sampleTime.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = _getStream() + .sampleTime(const Duration(milliseconds: 35)) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater( + controller.stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sampleTime.nullable', () { + nullableTest( + (s) => s.sampleTime(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/throttle_test.dart b/sandbox/reactivex/test/transformers/backpressure/throttle_test.dart new file mode 100644 index 0000000..3a125e8 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/throttle_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _stream() => + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1).take(10); + +void main() { + test('Rx.throttle', () async { + await expectLater( + _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttle.trailing', () async { + await expectLater( + _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250)), + trailing: true, + leading: false) + .take(3), + emitsInOrder([3, 6, 9, emitsDone])); + }); + + test('Rx.throttle.dynamic.window', () async { + await expectLater( + _stream() + .throttle((value) => value == 1 + ? Stream.periodic(const Duration(milliseconds: 10)) + : Stream.periodic(const Duration(milliseconds: 250))) + .take(3), + emitsInOrder([1, 2, 5, emitsDone])); + }); + + test('Rx.throttle.dynamic.window.trailing', () async { + await expectLater( + _stream() + .throttle( + (value) => value == 1 + ? Stream.periodic(const Duration(milliseconds: 10)) + : Stream.periodic(const Duration(milliseconds: 250)), + trailing: true, + leading: false) + .take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttle.leading.trailing.1', () async { + // --1--2--3--4--5--6--7--8--9--10--11| + // --1-----3--4-----6--7-----9--10-----11| + // --^--------^--------^---------^----- + + final values = []; + + final stream = _stream() + .concatWith([Rx.timer(11, const Duration(milliseconds: 100))]).throttle( + (v) { + values.add(v); + return Stream.periodic(const Duration(milliseconds: 250)); + }, + leading: true, + trailing: true, + ); + await expectLater( + stream, + emitsInOrder([1, 3, 4, 6, 7, 9, 10, 11, emitsDone]), + ); + expect(values, [1, 4, 7, 10]); + }); + + test('Rx.throttle.leading.trailing.2', () async { + // --1--2--3--4--5--6--7--8--9--10--11| + // --1-----3--4-----6--7-----9--10-----11| + // --^--------^--------^---------^----- + + final values = []; + + final stream = _stream().throttle( + (v) { + values.add(v); + return Stream.periodic(const Duration(milliseconds: 250)); + }, + leading: true, + trailing: true, + ); + await expectLater( + stream, + emitsInOrder([1, 3, 4, 6, 7, 9, 10, emitsDone]), + ); + expect(values, [1, 4, 7, 10]); + }); + + test('Rx.throttle.reusable', () async { + final transformer = ThrottleStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + }); + + test('Rx.throttle.asBroadcastStream', () async { + final future = _stream() + .asBroadcastStream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.throttle.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()).throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.throttle.pause.resume', () async { + late StreamSubscription subscription; + + final controller = StreamController(); + + subscription = _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .take(2) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 4, emitsDone])); + + await Future.delayed(const Duration(milliseconds: 150)).whenComplete( + () => subscription + .pause(Future.delayed(const Duration(milliseconds: 150)))); + }); + + test('Rx.throttle.nullable', () { + nullableTest( + (s) => s.throttle((_) => Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/throttle_time_test.dart b/sandbox/reactivex/test/transformers/backpressure/throttle_time_test.dart new file mode 100644 index 0000000..a0d55bf --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/throttle_time_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _stream() => + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1).take(10); + +void main() { + test('Rx.throttleTime', () async { + await expectLater( + _stream().throttleTime(const Duration(milliseconds: 250)).take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttleTime.trailing', () async { + await expectLater( + _stream() + .throttleTime(const Duration(milliseconds: 250), + trailing: true, leading: false) + .take(3), + emitsInOrder([3, 6, 9, emitsDone])); + }); + + test('Rx.throttleTime.reusable', () async { + final transformer = ThrottleStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + }); + + test('Rx.throttleTime.asBroadcastStream', () async { + final future = _stream() + .asBroadcastStream() + .throttleTime(const Duration(milliseconds: 250)) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.throttleTime.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .throttleTime(const Duration(milliseconds: 200)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.throttleTime.pause.resume', () async { + late StreamSubscription subscription; + + final controller = StreamController(); + + subscription = _stream() + .throttleTime(const Duration(milliseconds: 250)) + .take(2) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 4, emitsDone])); + + await Future.delayed(const Duration(milliseconds: 150)).whenComplete( + () => subscription + .pause(Future.delayed(const Duration(milliseconds: 150)))); + }); + + test('issue/417 trailing true', () async { + await expectLater( + Stream.fromIterable([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 25)) + .throttleTime(Duration(milliseconds: 50), + trailing: true, leading: false), + emitsInOrder([1, 3, 5, 7, 9, emitsDone])); + }); + + test('issue/417 trailing false', () async { + await expectLater( + Stream.fromIterable([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 25)) + .throttleTime(Duration(milliseconds: 50), trailing: false), + emitsInOrder([0, 2, 4, 6, 8, emitsDone])); + }); + + test('Rx.throttleTime.nullable', () { + nullableTest( + (s) => s.throttleTime(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/window_count_test.dart b/sandbox/reactivex/test/transformers/backpressure/window_count_test.dart new file mode 100644 index 0000000..58d252f --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/window_count_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.windowCount.noStartBufferEvery', () async { + await expectLater( + Rx.range(1, 4).windowCount(2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + [1, 2], + [3, 4], + emitsDone + ])); + }); + + test('Rx.windowCount.noStartBufferEvery.includesEventOnClose', () async { + await expectLater( + Rx.range(1, 5).windowCount(2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + const [5], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count2startBufferEvery1', () async { + await expectLater( + Rx.range(1, 4).windowCount(2, 1).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [2, 3], + const [3, 4], + const [4], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count3startBufferEvery2', () async { + await expectLater( + Rx.range(1, 8).windowCount(3, 2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2, 3], + const [3, 4, 5], + const [5, 6, 7], + const [7, 8], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count3startBufferEvery4', () async { + await expectLater( + Rx.range(1, 8).windowCount(3, 4).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2, 3], + const [5, 6, 7], + emitsDone + ])); + }); + + test('Rx.windowCount.reusable', () async { + final transformer = WindowCountStreamTransformer(2); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowCount.asBroadcastStream', () async { + final future = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .windowCount(2) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowCount.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).windowCount(2), + emitsError(isException), + ); + }); + + test( + 'Rx.windowCount.shouldThrow.invalidCount.negative', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).windowCount(-1), + throwsArgumentError); + }, + ); + + test('Rx.windowCount.startBufferEvery.shouldThrow.invalidStartBufferEvery', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).windowCount(2, -1), + throwsArgumentError); + }); + + test('Rx.windowCount.nullable', () { + nullableTest>( + (s) => s.windowCount(2), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/window_test.dart b/sandbox/reactivex/test/transformers/backpressure/window_test.dart new file mode 100644 index 0000000..33dba73 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/window_test.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream getStream(int n) async* { + var k = 0; + + while (k < n) { + await Future.delayed(const Duration(milliseconds: 100)); + + yield k++; + } +} + +void main() { + test('Rx.window', () async { + await expectLater( + getStream(4) + .window(Stream.periodic(const Duration(milliseconds: 160)) + .take(3)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.window.sampleBeforeEvent.shouldEmit', () async { + await expectLater( + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => 'end')) + .startWith('start') + .window(Stream.periodic(const Duration(milliseconds: 40)) + .take(10)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const ['start'], // after 40ms + const [], // 80ms + const [], // 120ms + const [], // 160ms + const ['end'], // done + emitsDone + ])); + }); + + test('Rx.window.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .window(Stream.periodic(const Duration(seconds: 3))) + .asyncMap((stream) => stream.toList()) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.window.reusable', () async { + final transformer = WindowStreamTransformer((_) => + Stream.periodic(const Duration(milliseconds: 160)) + .take(3) + .asBroadcastStream()); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.window.asBroadcastStream', () async { + final future = getStream(4) + .asBroadcastStream() + .window(Stream.periodic(const Duration(milliseconds: 160)) + .take(10) + .asBroadcastStream()) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.window.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .window(Stream.periodic(const Duration(milliseconds: 160))), + emitsError(isException)); + }); + + test('Rx.window.nullable', () { + nullableTest>( + (s) => s.window(Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/window_test_test.dart b/sandbox/reactivex/test/transformers/backpressure/window_test_test.dart new file mode 100644 index 0000000..b59f8f7 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/window_test_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.windowTest', () async { + await expectLater( + Rx.range(1, 4) + .windowTest((i) => i % 2 == 0) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowTest.reusable', () async { + final transformer = WindowTestStreamTransformer((i) => i % 2 == 0); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowTest.asBroadcastStream', () async { + final future = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .windowTest((i) => i % 2 == 0) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowTest.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).windowTest((i) => i % 2 == 0), + emitsError(isException)); + }); + + test('Rx.windowTest.nullable', () { + nullableTest>( + (s) => s.windowTest((_) => true), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/backpressure/window_time_test.dart b/sandbox/reactivex/test/transformers/backpressure/window_time_test.dart new file mode 100644 index 0000000..8c0e1f4 --- /dev/null +++ b/sandbox/reactivex/test/transformers/backpressure/window_time_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +/// yield immediately, then every 100ms +Stream getStream(int n) async* { + var k = 1; + + yield 0; + + while (k < n) { + yield await Future.delayed(const Duration(milliseconds: 100)) + .then((_) => k++); + } +} + +void main() { + test('Rx.windowTime', () async { + await expectLater( + getStream(4) + .windowTime(const Duration(milliseconds: 160)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.windowTime.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .windowTime(const Duration(seconds: 3)) + .asyncMap((stream) => stream.toList()) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.windowTime.reusable', () async { + final transformer = WindowStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 160))); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + }); + + test('Rx.windowTime.asBroadcastStream', () async { + final future = getStream(4) + .asBroadcastStream() + .windowTime(const Duration(milliseconds: 160)) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .windowTime(const Duration(milliseconds: 160)), + emitsError(isException)); + }); + + test('Rx.windowTime.nullable', () { + nullableTest>( + (s) => s.windowTime(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/concat_with_test.dart b/sandbox/reactivex/test/transformers/concat_with_test.dart new file mode 100644 index 0000000..6535b42 --- /dev/null +++ b/sandbox/reactivex/test/transformers/concat_with_test.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.concatWith', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + const expected = [1, 2]; + var count = 0; + + delayedStream.concatWith([immediateStream]).listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + test('Rx.concatWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.concatWith([Stream.empty()]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.concatWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [1, 2, emitsDone]; + + final concatenatedStream = delayedStream.concatWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); +} diff --git a/sandbox/reactivex/test/transformers/default_if_empty_test.dart b/sandbox/reactivex/test/transformers/default_if_empty_test.dart new file mode 100644 index 0000000..d6325a1 --- /dev/null +++ b/sandbox/reactivex/test/transformers/default_if_empty_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.defaultIfEmpty.whenEmpty', () async { + Stream.empty() + .defaultIfEmpty(true) + .listen(expectAsync1((bool result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.defaultIfEmpty.reusable', () async { + final transformer = DefaultIfEmptyStreamTransformer(true); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.defaultIfEmpty.whenNotEmpty', () async { + Stream.fromIterable(const [false, false, false]) + .defaultIfEmpty(true) + .listen(expectAsync1((result) { + expect(result, false); + }, count: 3)); + }); + + test('Rx.defaultIfEmpty.asBroadcastStream', () async { + final stream = Stream.fromIterable(const []) + .defaultIfEmpty(-1) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.defaultIfEmpty.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).defaultIfEmpty(-1); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.defaultIfEmpty.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const []).defaultIfEmpty(1); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.defaultIfEmpty accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.defaultIfEmpty(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.defaultIfEmpty.nullable', () { + nullableTest( + (s) => s.defaultIfEmpty(null), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/delay_test.dart b/sandbox/reactivex/test/transformers/delay_test.dart new file mode 100644 index 0000000..3c24d34 --- /dev/null +++ b/sandbox/reactivex/test/transformers/delay_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.delay', () async { + var value = 1; + _getStream() + .delay(const Duration(milliseconds: 200)) + .listen(expectAsync1((result) { + expect(result, value++); + }, count: 4)); + }); + + test('Rx.delay.zero', () { + expect( + _getStream().delay(Duration.zero), + emitsInOrder([1, 2, 3, 4]), + ); + }); + + test('Rx.delay.shouldBeDelayed', () async { + var value = 1; + _getStream() + .delay(const Duration(milliseconds: 500)) + .timeInterval() + .listen(expectAsync1((result) { + expect(result.value, value++); + + if (result.value == 1) { + expect(result.interval.inMilliseconds, + greaterThanOrEqualTo(500)); // should be delayed + } else { + expect(result.interval.inMilliseconds, + lessThanOrEqualTo(20)); // should be near instantaneous + } + }, count: 4)); + }); + + test('Rx.delay.reusable', () async { + final transformer = + DelayStreamTransformer(const Duration(milliseconds: 200)); + var valueA = 1, valueB = 1; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueA++); + }, count: 4)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueB++); + }, count: 4)); + }); + + test('Rx.delay.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .delay(const Duration(milliseconds: 200)); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.delay.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .delay(const Duration(milliseconds: 200)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.delay.pause.resume', () async { + late StreamSubscription subscription; + final stream = + Stream.fromIterable(const [1, 2, 3]).delay(Duration(milliseconds: 1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test( + 'Rx.delay.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).delay(Duration(seconds: 10)); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.delay accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.delay(Duration(seconds: 10)); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.delay.nullable', () { + nullableTest( + (s) => s.delay(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/delay_when_test.dart b/sandbox/reactivex/test/transformers/delay_when_test.dart new file mode 100644 index 0000000..2e37a11 --- /dev/null +++ b/sandbox/reactivex/test/transformers/delay_when_test.dart @@ -0,0 +1,280 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +extension on Duration { + Stream asTimerStream() => Rx.timer(null, this); +} + +void main() { + test('Rx.delayWhen', () { + expect( + _getStream().delayWhen((_) => Stream.value(null)), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream() + .delayWhen((i) => Duration(milliseconds: 100 * i).asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream().delayWhen( + (i) => Duration(milliseconds: 100 * i).asTimerStream(), + listenDelay: Rx.timer(null, Duration(milliseconds: 100)), + ), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.zero', () { + expect( + _getStream().delayWhen((_) => Duration.zero.asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.shouldBeDelayed', () async { + { + var value = 1; + await _getStream() + .delayWhen((_) => const Duration(milliseconds: 500).asTimerStream()) + .timeInterval() + .forEach(expectAsync1((result) { + expect(result.value, value++); + + if (result.value == 1) { + expect( + result.interval.inMilliseconds, + greaterThanOrEqualTo(500), + ); // should be delayed + } else { + expect( + result.interval.inMilliseconds, + lessThanOrEqualTo(20), + ); // should be near instantaneous + } + }, count: 4)); + } + + { + var value = 1; + await _getStream() + .delayWhen((i) => Duration(milliseconds: 500 * i).asTimerStream()) + .timeInterval() + .forEach(expectAsync1((result) { + expect(result.value, value++); + + expect( + (result.interval.inMilliseconds - 500).abs(), + lessThanOrEqualTo(20), + ); // should be near instantaneous + }, count: 4)); + } + }); + + test('Rx.delayWhen.shouldBeDelayed.listenDelay', () { + var value = 1; + + void onData(TimeInterval result) { + expect(result.value, value++); + + if (result.value == 1) { + expect( + result.interval.inMilliseconds, + greaterThanOrEqualTo(500 + 300), + ); // should be delayed + } else { + expect( + (result.interval.inMilliseconds - 500).abs(), + lessThanOrEqualTo(20), + ); // should be near instantaneous + } + } + + _getStream() + .delayWhen( + (i) => Duration(milliseconds: 500 * i).asTimerStream(), + listenDelay: Rx.timer(null, const Duration(milliseconds: 300)), + ) + .timeInterval() + .listen(expectAsync1(onData, count: 4)); + }); + + test('Rx.delayWhen.reusable', () { + final transformer = DelayWhenStreamTransformer( + (_) => const Duration(milliseconds: 200).asTimerStream()); + + expect( + _getStream().transform(transformer), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream().transform(transformer), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.asBroadcastStream', () { + { + final stream = _getStream() + .asBroadcastStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + + { + final stream = _getStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + + { + final stream = _getStream() + .delayWhen( + (_) => const Duration(milliseconds: 200).asTimerStream(), + listenDelay: Stream.value(null), + ) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + }); + + test('Rx.delayWhen.error.shouldThrowA', () { + expect( + Stream.error(Exception()) + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()), + emitsInOrder([ + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('Rx.delayWhen.error.shouldThrowB', () { + expect( + Stream.value(0).delayWhen( + (_) => const Duration(milliseconds: 200).asTimerStream(), + listenDelay: Stream.error(Exception('listenDelay')), + ), + emitsInOrder([ + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('Rx.delayWhen.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]) + .delayWhen((_) => Duration(milliseconds: 1).asTimerStream()); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.delayWhen.pause.resume.listenDelay', () { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).delayWhen( + (_) => Duration(milliseconds: 1).asTimerStream(), + listenDelay: Rx.timer(null, const Duration(milliseconds: 200)), + ); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test( + 'Rx.delayWhen.cancel.emits.nothing', + () { + late StreamSubscription subscription; + final stream = _getStream() + .doOnDone(() => subscription.cancel()) + .delayWhen((_) => Duration(seconds: 10).asTimerStream()); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test( + 'Rx.delayWhen.cancel.emits.nothing.listenDelay', + () { + late StreamSubscription subscription; + final stream = + _getStream().doOnDone(() => subscription.cancel()).delayWhen( + (_) => Duration(seconds: 10).asTimerStream(), + listenDelay: Stream.periodic(const Duration(seconds: 1)), + ); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.delayWhen.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream + .delayWhen((_) => Duration(seconds: 10).asTimerStream()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.delayWhen.nullable', () { + nullableTest( + (s) => s.delayWhen((_) => Duration.zero.asTimerStream()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/dematerialize_test.dart b/sandbox/reactivex/test/transformers/dematerialize_test.dart new file mode 100644 index 0000000..c4fdb57 --- /dev/null +++ b/sandbox/reactivex/test/transformers/dematerialize_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.dematerialize.happyPath', () async { + const expectedValue = 1; + final stream = Stream.value(1).materialize(); + + stream.dematerialize().listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('Rx.dematerialize.nullable.happyPath', () async { + const elements = [1, 2, null, 3, 4, null]; + final stream = Stream.fromIterable(elements).materialize(); + + expect( + stream.dematerialize(), + emitsInOrder(elements), + ); + }); + + test('Rx.dematerialize.reusable', () async { + final transformer = DematerializeStreamTransformer(); + const expectedValue = 1; + final streamA = Stream.value(1).materialize(); + final streamB = Stream.value(1).materialize(); + + streamA.transform(transformer).listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + + streamB.transform(transformer).listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('dematerializeTransformer.happyPath', () async { + const expectedValue = 1; + final stream = Stream.fromIterable([ + StreamNotification.data(expectedValue), + StreamNotification.done() + ]); + + stream.transform(DematerializeStreamTransformer()).listen( + expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('dematerializeTransformer.sadPath', () async { + final stream = Stream.fromIterable( + [StreamNotification.error(Exception(), Chain.current())]); + + stream.transform(DematerializeStreamTransformer()).listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('dematerializeTransformer.onPause.onResume', () async { + const expectedValue = 1; + final stream = Stream.fromIterable([ + StreamNotification.data(expectedValue), + StreamNotification.done() + ]); + + stream.transform(DematerializeStreamTransformer()).listen( + expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })) + ..pause() + ..resume(); + }); + + test('Rx.dematerialize accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.materialize().dematerialize(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/sandbox/reactivex/test/transformers/distinct_test.dart b/sandbox/reactivex/test/transformers/distinct_test.dart new file mode 100644 index 0000000..0775b7f --- /dev/null +++ b/sandbox/reactivex/test/transformers/distinct_test.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.distinct', () async { + const expected = 1; + + final stream = Stream.fromIterable(const [expected, expected]).distinct(); + + stream.listen(expectAsync1((actual) { + expect(actual, expected); + })); + }); + test('Rx.distinct accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.distinct(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/sandbox/reactivex/test/transformers/distinct_unique_test.dart b/sandbox/reactivex/test/transformers/distinct_unique_test.dart new file mode 100644 index 0000000..6c4c9dd --- /dev/null +++ b/sandbox/reactivex/test/transformers/distinct_unique_test.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('DistinctUniqueStreamTransformer', () { + test('works with the equals and hascode of the class', () async { + final stream = Stream.fromIterable(const [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]).distinctUnique(); + + await expectLater( + stream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test('works with a provided equals and hashcode', () async { + final stream = Stream.fromIterable(const [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]).distinctUnique( + equals: (a, b) => a.key == b.key, hashCode: (o) => o.key.hashCode); + + await expectLater( + stream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test( + 'sends an error to the subscription if an error occurs in the equals or hashmap methods', + () async { + final stream = Stream.fromIterable( + const [_TestObject('a'), _TestObject('b'), _TestObject('c')]) + .distinctUnique( + equals: (a, b) => a.key == b.key, + hashCode: (o) => throw Exception('Catch me if you can!')); + + stream.listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 3, + ), + ); + }); + + test('is reusable', () async { + const data = [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]; + + final distinctUniqueStreamTransformer = + DistinctUniqueStreamTransformer<_TestObject>(); + + final firstStream = + Stream.fromIterable(data).transform(distinctUniqueStreamTransformer); + + final secondStream = + Stream.fromIterable(data).transform(distinctUniqueStreamTransformer); + + await expectLater( + firstStream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + + await expectLater( + secondStream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test('Rx.distinctUnique accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.distinctUnique(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + }); + + test('Rx.distinctUnique.nullable', () { + nullableTest( + (s) => s.distinctUnique(), + ); + }); +} + +class _TestObject { + final String key; + + const _TestObject(this.key); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _TestObject && + runtimeType == other.runtimeType && + key == other.key; + + @override + int get hashCode => key.hashCode; + + @override + String toString() => key; +} diff --git a/sandbox/reactivex/test/transformers/do_test.dart b/sandbox/reactivex/test/transformers/do_test.dart new file mode 100644 index 0000000..b99fe28 --- /dev/null +++ b/sandbox/reactivex/test/transformers/do_test.dart @@ -0,0 +1,489 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('DoStreamTranformer', () { + test('calls onDone when the stream is finished', () async { + var onDoneCalled = false; + final stream = Stream.empty().doOnDone(() => onDoneCalled = true); + + await expectLater(stream, emitsDone); + await expectLater(onDoneCalled, isTrue); + }); + + test('calls onError when an error is emitted', () async { + var onErrorCalled = false; + final stream = Stream.error(Exception()) + .doOnError((e, s) => onErrorCalled = true); + + await expectLater(stream, emitsError(isException)); + await expectLater(onErrorCalled, isTrue); + }); + + test( + 'onError only called once when an error is emitted on a broadcast stream', + () async { + var count = 0; + final subject = BehaviorSubject(sync: true); + final stream = subject.stream.doOnError((e, s) => count++); + + stream.listen(null, onError: (dynamic e, dynamic s) {}); + stream.listen(null, onError: (dynamic e, dynamic s) {}); + + subject.addError(Exception()); + subject.addError(Exception()); + + await expectLater(count, 2); + await subject.close(); + }); + + test('calls onCancel when the subscription is cancelled', () async { + var onCancelCalled = false; + final stream = Stream.value(1); + + await stream + .doOnCancel(() => onCancelCalled = true) + .listen(null) + .cancel(); + + await expectLater(onCancelCalled, isTrue); + }); + + test('awaits onCancel when the subscription is cancelled', () async { + var onCancelCompleted = 10, onCancelHandled = 10, eventSequenceCount = 0; + final stream = Stream.value(1); + + await stream + .doOnCancel(() => + Future.delayed(const Duration(milliseconds: 100)) + .whenComplete(() => onCancelHandled = ++eventSequenceCount)) + .listen(null) + .cancel() + .whenComplete(() => onCancelCompleted = ++eventSequenceCount); + + await expectLater(onCancelCompleted > onCancelHandled, isTrue); + }); + + test( + 'onCancel called only once when the subscription is multiple listeners', + () async { + var count = 0; + final subject = BehaviorSubject(sync: true); + final stream = subject.doOnCancel(() => count++); + + await stream.listen(null).cancel(); + await stream.listen(null).cancel(); + + await expectLater(count, 2); + await subject.close(); + }); + + test('calls onData when the stream emits an item', () async { + var onDataCalled = false; + final stream = Stream.value(1).doOnData((_) => onDataCalled = true); + + await expectLater(stream, emits(1)); + await expectLater(onDataCalled, isTrue); + }); + + test('onData only emits once for broadcast streams with multiple listeners', + () async { + final actual = []; + final controller = StreamController.broadcast(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onData: actual.add)); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(actual, const [1, 2]); + await controller.close(); + }); + + test('onData only emits once for subjects with multiple listeners', + () async { + final actual = []; + final controller = BehaviorSubject(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onData: actual.add)); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(actual, const [1, 2]); + await controller.close(); + }); + + test('onData only emits correctly with ReplaySubject', () async { + final controller = ReplaySubject(sync: true) + ..add(1) + ..add(2); + final actual = []; + + await controller.close(); + + expect(await controller.stream.doOnData(actual.add).drain(actual), + const [1, 2]); + + actual.clear(); + + expect(await controller.stream.doOnData(actual.add).drain(actual), + const [1, 2]); + }); + + test('emits onEach Notifications for Data, Error, and Done', () async { + StackTrace? stacktrace; + final actual = >[]; + final exception = Exception(); + final stream = Stream.value(1) + .concatWith([Stream.error(exception)]).doOnEach((notification) { + actual.add(notification); + + if (notification.isError) { + stacktrace = notification.errorAndStackTraceOrNull?.stackTrace; + } + }); + + await expectLater(stream, + emitsInOrder([1, emitsError(isException), emitsDone])); + + await expectLater(actual, [ + StreamNotification.data(1), + StreamNotification.error(exception, stacktrace), + StreamNotification.done() + ]); + }); + + test('onEach only emits once for broadcast streams with multiple listeners', + () async { + var count = 0; + final controller = StreamController.broadcast(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onEach: (_) { + count++; + })); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(count, 2); + await controller.close(); + }); + + test('calls onListen when a consumer listens', () async { + var onListenCalled = false; + final stream = Stream.empty().doOnListen(() { + onListenCalled = true; + }); + + await expectLater(stream, emitsDone); + await expectLater(onListenCalled, isTrue); + }); + + test( + 'calls onListen once when multiple subscribers open, without cancelling', + () async { + var onListenCallCount = 0; + final sc = StreamController.broadcast() + ..add(1) + ..add(2) + ..add(3); + + final stream = sc.stream.doOnListen(() => onListenCallCount++); + + stream.listen(null); + stream.listen(null); + + await expectLater(onListenCallCount, 1); + await sc.close(); + }); + + test( + 'calls onListen every time after all previous subscribers have cancelled', + () async { + var onListenCallCount = 0; + final sc = StreamController.broadcast() + ..add(1) + ..add(2) + ..add(3); + + final stream = sc.stream.doOnListen(() => onListenCallCount++); + + await stream.listen(null).cancel(); + await stream.listen(null).cancel(); + + await expectLater(onListenCallCount, 2); + await sc.close(); + }); + + test('calls onPause and onResume when the subscription is', () async { + var onPauseCalled = false, onResumeCalled = false; + final stream = Stream.value(1).doOnPause(() { + onPauseCalled = true; + }).doOnResume(() { + onResumeCalled = true; + }); + + stream.listen(null, onDone: expectAsync0(() { + expect(onPauseCalled, isTrue); + expect(onResumeCalled, isTrue); + })) + ..pause() + ..resume(); + }); + + test('should be reusable', () async { + var callCount = 0; + final transformer = DoStreamTransformer(onData: (_) { + callCount++; + }); + + final streamA = Stream.value(1).transform(transformer), + streamB = Stream.value(1).transform(transformer); + + await expectLater(streamA, emitsInOrder([1, emitsDone])); + await expectLater(streamB, emitsInOrder([1, emitsDone])); + + expect(callCount, 2); + }); + + test('throws an error when no arguments are provided', () { + expect(() => DoStreamTransformer(), throwsArgumentError); + }); + + test('should propagate errors', () { + Stream.value(1) + .doOnListen(() => throw Exception('catch me if you can! doOnListen')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.value(1) + .doOnData((_) => throw Exception('catch me if you can! doOnData')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.error(Exception('oh noes!')) + .doOnError( + (_, __) => throw Exception('catch me if you can! doOnError')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + // a cancel() call may occur after the controller is already closed + // in that case, the error is forwarded to the current [Zone] + runZonedGuarded( + () { + Stream.value(1) + .doOnCancel(() => + throw Exception('catch me if you can! doOnCancel-zoned')) + .listen(null); + + Stream.value(1) + .doOnCancel( + () => throw Exception('catch me if you can! doOnCancel')) + .listen(null) + .cancel(); + }, + expectAsync2( + (Object e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + Stream.value(1) + .doOnDone(() => throw Exception('catch me if you can! doOnDone')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.value(1) + .doOnEach((_) => throw Exception('catch me if you can! doOnEach')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + Stream.value(1) + .doOnPause(() => throw Exception('catch me if you can! doOnPause')) + .listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + )) + ..pause() + ..resume(); + + Stream.value(1) + .doOnResume(() => throw Exception('catch me if you can! doOnResume')) + .listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException))) + ..pause() + ..resume(); + }); + + test( + 'doOnListen correctly allows subscribing multiple times on a broadcast stream', + () { + final controller = StreamController.broadcast(); + final stream = controller.stream.doOnListen(() { + // do nothing + }); + + controller.close(); + + expectLater(stream, emitsDone); + expectLater(stream, emitsDone); + }); + + test('issue/389/1', () { + final controller = StreamController.broadcast(); + final stream = controller.stream.doOnListen(() { + // do nothing + }); + + expectLater(stream, emitsDone); + expectLater(stream, emitsDone); // #issue/389 : is being ignored/hangs up + + controller.close(); + }); + + test('issue/389/2', () { + final controller = StreamController(); + var isListening = false; + + final stream = controller.stream.doOnListen(() { + isListening = true; + }); + + controller.close(); + + // should be done + expectLater(stream, emitsDone); + // should have called onX + expect(isListening, true); + // should not be converted to a broadcast Stream + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.do accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.doOnEach((_) {}); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('nested doOnX', () async { + final completer = Completer(); + final stream = + Rx.range(0, 30).interval(const Duration(milliseconds: 100)); + final result = []; + const expectedOutput = [ + 'A: 0', + 'B: 0', + 'pause', + 'A: 1', + 'B: 1', + 'A: 2', + 'B: 2', + 'A: 3', + 'B: 3', + 'A: 4', + 'B: 4', + 'A: 5', + 'B: 5', + 'pause', + 'A: 6', + 'B: 6', + 'A: 7', + 'B: 7', + 'A: 8', + 'B: 8', + 'A: 9', + 'B: 9', + 'A: 10', + 'B: 10', + 'pause', + 'A: 11', + 'B: 11', + 'A: 12', + 'B: 12', + 'A: 13', + 'B: 13', + 'A: 14', + 'B: 14', + 'A: 15', + 'B: 15', + 'pause', + 'A: 16', + 'B: 16', + 'A: 17', + ]; + late StreamSubscription subscription; + + void addToResult(String value) { + result.add(value); + + if (result.length == expectedOutput.length) { + subscription.cancel(); + completer.complete(); + } + } + + subscription = Stream.value(1) + .exhaustMap((_) => stream.doOnData((data) => addToResult('A: $data'))) + .doOnPause(() => addToResult('pause')) + .doOnData((data) => addToResult('B: $data')) + .take(expectedOutput.length) + .listen((value) { + if (value % 5 == 0) { + subscription.pause(Future.delayed(const Duration(seconds: 2))); + } + }); + + await completer.future; + + expect(result, expectedOutput); + }); + + test('doOnData nullable', () { + nullableTest( + (s) => s.doOnData((d) {}), + ); + }); + }); +} diff --git a/sandbox/reactivex/test/transformers/end_with_many_test.dart b/sandbox/reactivex/test/transformers/end_with_many_test.dart new file mode 100644 index 0000000..ab28437 --- /dev/null +++ b/sandbox/reactivex/test/transformers/end_with_many_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.endWithMany', () async { + const expectedOutput = [1, 2, 3, 4, 5, 6]; + + await expectLater( + _getStream().endWithMany(const [5, 6]), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWithMany.reusable', () async { + final transformer = EndWithManyStreamTransformer(const [5, 6]); + const expectedOutput = [1, 2, 3, 4, 5, 6]; + + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWithMany.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().endWithMany(const [5, 6]); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.endWithMany.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).endWithMany(const [5, 6]); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.endWithMany.pause.resume', () async { + const expectedOutput = [1, 2, 3, 4, 5, 6]; + var count = 0; + + late StreamSubscription subscription; + subscription = + _getStream().endWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.endWithMany accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.endWithMany(const [1, 2, 3]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.endWithMany.nullable', () { + nullableTest( + (s) => s.endWithMany(['String']), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/end_with_test.dart b/sandbox/reactivex/test/transformers/end_with_test.dart new file mode 100644 index 0000000..d54562b --- /dev/null +++ b/sandbox/reactivex/test/transformers/end_with_test.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.endWith', () async { + const expectedOutput = [1, 2, 3, 4, 5]; + + await expectLater(_getStream().endWith(5), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWith.reusable', () async { + final transformer = EndWithStreamTransformer(5); + const expectedOutput = [1, 2, 3, 4, 5]; + + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWith.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().endWith(5); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.endWith.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).endWith(5); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.endWith.pause.resume', () async { + const expectedOutput = [1, 2, 3, 4, 5]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().endWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.endWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.endWith(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.endWith.nullable', () { + nullableTest( + (s) => s.endWith('String'), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/exhaust_map_test.dart b/sandbox/reactivex/test/transformers/exhaust_map_test.dart new file mode 100644 index 0000000..0f9137f --- /dev/null +++ b/sandbox/reactivex/test/transformers/exhaust_map_test.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('ExhaustMap', () { + test('does not create a new Stream while emitting', () async { + var calls = 0; + final stream = Rx.range(0, 9).exhaustMap((i) { + calls++; + return Rx.timer(i, Duration(milliseconds: 100)); + }); + + await expectLater(stream, emitsInOrder([0, emitsDone])); + await expectLater(calls, 1); + }); + + test('starts emitting again after previous Stream is complete', () async { + final stream = Stream.fromIterable(const [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 30)) + .exhaustMap((i) async* { + yield await Future.delayed(Duration(milliseconds: 70), () => i); + }); + + await expectLater(stream, emitsInOrder([0, 3, 6, 9, emitsDone])); + }); + + test('is reusable', () async { + final transformer = ExhaustMapStreamTransformer( + (int i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater( + Rx.range(0, 9).transform(transformer), + emitsInOrder([0, emitsDone]), + ); + + await expectLater( + Rx.range(0, 9).transform(transformer), + emitsInOrder([0, emitsDone]), + ); + }); + + test('works as a broadcast stream', () async { + final stream = Rx.range(0, 9) + .asBroadcastStream() + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater(() { + stream.listen(null); + stream.listen(null); + }, returnsNormally); + }); + + test('should emit errors from source', () async { + final streamWithError = Stream.error(Exception()) + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('should emit errors from mapped stream', () async { + final streamWithError = Stream.value(1).exhaustMap( + (_) => Stream.error(Exception('Catch me if you can!'))); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('should emit errors thrown in the mapper', () async { + final streamWithError = Stream.value(1).exhaustMap((_) { + throw Exception('oh noes!'); + }); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('can be paused and resumed', () async { + late StreamSubscription subscription; + final stream = Rx.range(0, 9) + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 20))); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 0); + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.exhaustMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.exhaustMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.exhaustMap.nullable', () { + nullableTest( + (s) => s.exhaustMap((v) => Stream.value(v)), + ); + }); + }); +} diff --git a/sandbox/reactivex/test/transformers/flat_map_iterable_test.dart b/sandbox/reactivex/test/transformers/flat_map_iterable_test.dart new file mode 100644 index 0000000..e1cb944 --- /dev/null +++ b/sandbox/reactivex/test/transformers/flat_map_iterable_test.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('Rx.flatMapIterable', () { + test('transforms a Stream> into individual items', () { + expect( + Rx.range(1, 4) + .flatMapIterable((int i) => Stream>.value([i])), + emitsInOrder([1, 2, 3, 4, emitsDone])); + }); + + test('accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream + .flatMapIterable((int i) => Stream>.value([i])); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('nullable', () { + nullableTest( + (s) => s.flatMapIterable((v) => Stream.value([v])), + ); + }); + }); +} diff --git a/sandbox/reactivex/test/transformers/flat_map_test.dart b/sandbox/reactivex/test/transformers/flat_map_test.dart new file mode 100644 index 0000000..db994f0 --- /dev/null +++ b/sandbox/reactivex/test/transformers/flat_map_test.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.flatMap', () async { + const expectedOutput = [3, 2, 1]; + var count = 0; + + _getStream().flatMap(_getOtherStream).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.flatMap.reusable', () async { + final transformer = FlatMapStreamTransformer(_getOtherStream); + const expectedOutput = [3, 2, 1]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.flatMap.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().flatMap(_getOtherStream); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.flatMap.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).flatMap(_getOtherStream); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.error.shouldThrowB', () async { + final streamWithError = Stream.value(1) + .flatMap((_) => Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.error.shouldThrowC', () async { + final streamWithError = + Stream.value(1).flatMap((_) => throw Exception('oh noes!')); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(0).flatMap((_) => Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.flatMap.chains', () { + expect( + Stream.value(1) + .flatMap((_) => Stream.value(2)) + .flatMap((_) => Stream.value(3)), + emitsInOrder([3, emitsDone]), + ); + }); + + test('Rx.flatMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.flatMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.flatMap(maxConcurrent: 1)', () { + { + // asyncExpand / concatMap + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, emitsInOrder([1, 2, 3, 4, emitsDone])); + } + + { + // emits error + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => value == 1 + ? throw Exception() + : Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, + emitsInOrder([emitsError(isException), 2, 3, 4, emitsDone])); + } + + { + // emits error + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => value == 1 + ? Stream.error(Exception()) + : Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, + emitsInOrder([emitsError(isException), 2, 3, 4, emitsDone])); + } + }); + + test('Rx.flatMap(maxConcurrent: 2)', () async { + const maxConcurrent = 2; + var activeCount = 0; + + // 1 -> 500 + // 2 -> 400 + // 3 -> 500 + // 4 -> 200 + // -----1--4 + // ----2-----3 + // ----21--4-3 + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) { + return Rx.defer(() { + expect(++activeCount, lessThanOrEqualTo(maxConcurrent)); + + final ms = (value.isOdd ? 5 : 6 - value) * 100; + return Rx.timer(value, Duration(milliseconds: ms)); + }).doOnDone(() => --activeCount); + }, + maxConcurrent: maxConcurrent, + ); + + await expectLater(stream, emitsInOrder([2, 1, 4, 3, emitsDone])); + }); + + test('Rx.flatMap(maxConcurrent: 3)', () async { + const maxConcurrent = 3; + var activeCount = 0; + + // 1 -> 400 + // 2 -> 300 + // 3 -> 200 + // 4 -> 200 + // 5 -> 300 + // 6 -> 400 + // ----1----6 + // ---2---5 + // --3--4 + // --3214-5-6 + final stream = Stream.fromIterable([1, 2, 3, 4, 5, 6]).flatMap( + (value) { + return Rx.defer(() { + expect(++activeCount, lessThanOrEqualTo(maxConcurrent)); + + final ms = (value <= 3 ? 5 - value : value - 2) * 100; + return Rx.timer(value, Duration(milliseconds: ms)); + }).doOnDone(() => --activeCount); + }, + maxConcurrent: maxConcurrent, + ); + + await expectLater( + stream, emitsInOrder([3, 2, 1, 4, 5, 6, emitsDone])); + }); + + test('Rx.flatMap.cancel', () { + _getStream() + .flatMap(_getOtherStream) + .listen(expectAsync1((data) {}, count: 0)) + .cancel(); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 1).cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 1) + .listen(expectAsync1((data) {}, count: 0)) + .cancel(); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap.take.cancel', () { + _getStream() + .flatMap(_getOtherStream) + .take(1) + .listen(expectAsync1((data) => expect(data, 3), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 1).take.cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 1) + .take(1) + .listen(expectAsync1((data) => expect(data, 1), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 2).take.cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 2) + .take(1) + .listen(expectAsync1((data) => expect(data, 2), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap.nullable', () { + nullableTest( + (s) => s.flatMap((v) => Stream.value(v)), + ); + }); +} + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3]); + +Stream _getOtherStream(int value) { + final controller = StreamController(); + + Timer( + // Reverses the order of 1, 2, 3 to 3, 2, 1 by delaying 1, and 2 longer + // than they delay 3 + Duration( + milliseconds: value == 1 + ? 15 + : value == 2 + ? 10 + : 5), () { + controller.add(value); + controller.close(); + }); + + return controller.stream; +} diff --git a/sandbox/reactivex/test/transformers/group_by_test.dart b/sandbox/reactivex/test/transformers/group_by_test.dart new file mode 100644 index 0000000..9b896ea --- /dev/null +++ b/sandbox/reactivex/test/transformers/group_by_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +String _toEventOdd(int value) => value == 0 ? 'even' : 'odd'; + +void main() { + test('Rx.groupBy', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]).groupBy((value) => value), + emitsInOrder([ + TypeMatcher>() + .having((stream) => stream.key, 'key', 1), + TypeMatcher>() + .having((stream) => stream.key, 'key', 2), + TypeMatcher>() + .having((stream) => stream.key, 'key', 3), + TypeMatcher>() + .having((stream) => stream.key, 'key', 4), + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value, durationSelector: (_) => Rx.never()), + emitsInOrder([ + TypeMatcher>() + .having((stream) => stream.key, 'key', 1), + TypeMatcher>() + .having((stream) => stream.key, 'key', 2), + TypeMatcher>() + .having((stream) => stream.key, 'key', 3), + TypeMatcher>() + .having((stream) => stream.key, 'key', 4), + emitsDone + ])); + }); + + test('Rx.groupBy.correctlyEmitsGroupEvents', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => _toEventOdd(value % 2)) + .flatMap((stream) => stream.map((event) => {stream.key: event})), + emitsInOrder([ + {'odd': 1}, + {'even': 2}, + {'odd': 3}, + {'even': 4}, + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => _toEventOdd(value % 2), + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 1)), + ) + .flatMap((stream) => stream.map((event) => {stream.key: event})), + emitsInOrder([ + {'odd': 1}, + {'even': 2}, + {'odd': 3}, + {'even': 4}, + emitsDone + ])); + }); + + test('Rx.groupBy.correctlyEmitsGroupEvents.alternate', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => _toEventOdd(value % 2)) + // fold is called when onDone triggers on the Stream + .map((stream) async => await stream.fold( + {stream.key: []}, + (Map> previous, element) => + previous..[stream.key]?.add(element))), + emitsInOrder([ + { + 'odd': [1, 3] + }, + { + 'even': [2, 4] + }, + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => _toEventOdd(value % 2), + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 1)), + ) + // fold is called when onDone triggers on the Stream + .map((stream) async => await stream.fold( + {stream.key: []}, + (Map> previous, element) => + previous..[stream.key]?.add(element))), + emitsInOrder([ + { + 'odd': [1, 3] + }, + { + 'even': [2, 4] + }, + emitsDone + ])); + }); + + test('Rx.groupBy.emittedStreamCallOnDone', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value) + // drain will emit 'done' onDone + .map((stream) async => await stream.drain('done')), + emitsInOrder(['done', 'done', 'done', 'done', emitsDone])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value, durationSelector: (_) => Rx.never()) + // drain will emit 'done' onDone + .map((stream) async => await stream.drain('done')), + emitsInOrder(['done', 'done', 'done', 'done', emitsDone])); + }); + + test('Rx.groupBy.asBroadcastStream', () async { + { + final stream = Stream.fromIterable([1, 2, 3, 4]) + .asBroadcastStream() + .groupBy((value) => value); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + } + + { + final stream = + Stream.fromIterable([1, 2, 3, 4]).asBroadcastStream().groupBy( + (value) => value, + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 2)), + ); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + } + }); + + test('Rx.groupBy.pause.resume', () async { + { + var count = 0; + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value) + .listen(expectAsync1((result) { + count++; + + if (count == 4) { + subscription.cancel(); + } + }, count: 4)); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 100))); + } + + { + var count = 0; + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => value, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ) + .listen(expectAsync1((result) { + count++; + + if (count == 4) { + subscription.cancel(); + } + }, count: 4)); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 100))); + } + }); + + test('Rx.groupBy.error.shouldThrow.onError', () async { + { + final streamWithError = + Stream.error(Exception()).groupBy((value) => value); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + } + + { + final streamWithError = Stream.error(Exception()).groupBy( + (value) => value, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + } + }); + + test('Rx.groupBy.error.shouldThrow.onGrouper', () async { + { + final streamWithError = + Stream.fromIterable([1, 2, 3, 4]).groupBy((value) { + throw Exception(); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 4)); + } + + { + final streamWithError = Stream.fromIterable([1, 2, 3, 4]).groupBy( + (value) => throw Exception(), + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 4)); + } + }); + test('Rx.groupBy accidental broadcast', () async { + { + final controller = StreamController(); + + final stream = controller.stream.groupBy((_) => _); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + } + + { + final controller = StreamController(); + + final stream = controller.stream.groupBy( + (_) => _, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + } + }); + + test('Rx.groupBy.durationSelector', () { + final g = [ + '0 -> 1', + '1 -> 1', + '2 -> 1', + '0 -> 2', + '1 -> 2', + '2 -> 2', + ]; + final take = 30; + + final stream = Stream.periodic(const Duration(milliseconds: 100), (i) => i) + .groupBy( + (i) => i % 3, + durationSelector: (i) => + Rx.timer(null, const Duration(milliseconds: 400)), + ) + .flatMap((g) => g + .scan((acc, value, index) => acc + 1, 0) + .map((event) => '${g.key} -> $event')) + .take(take); + + expect( + stream, + emitsInOrder([ + ...List.filled(take ~/ g.length, g).expand((e) => e), + emitsDone, + ]), + ); + }); + + test('Rx.groupBy.nullable', () { + nullableTest>( + (s) => s.groupBy((v) => v), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/ignore_elements_test.dart b/sandbox/reactivex/test/transformers/ignore_elements_test.dart new file mode 100644 index 0000000..9673be5 --- /dev/null +++ b/sandbox/reactivex/test/transformers/ignore_elements_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.ignoreElements', () async { + var hasReceivedEvent = false; + + _getStream().ignoreElements().listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + + expect( + _getStream().ignoreElements(), + emitsInOrder([emitsDone]), + ); + }); + + test('Rx.ignoreElements.cast', () { + final ignored = _getStream().ignoreElements(); + + expect(ignored, isA>()); + expect(ignored, isA>()); // ignore: prefer_void_to_null + expect(ignored, isA>()); + expect(ignored, isA>()); + expect(ignored, isA>()); + expect(ignored, isA>()); + + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast, prefer_void_to_null + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + + expect(true, true); + }); + + test('Rx.ignoreElements.reusable', () async { + final transformer = IgnoreElementsStreamTransformer(); + var hasReceivedEvent = false; + + _getStream().transform(transformer).listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + + _getStream().transform(transformer).listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + }); + + test('Rx.ignoreElements.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().ignoreElements(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.ignoreElements.pause.resume', () async { + var hasReceivedEvent = false; + + _getStream().ignoreElements().listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)) + ..pause() + ..resume(); + }); + + test('Rx.ignoreElements.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).ignoreElements(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 1)); + }); + + test('Rx.ignoreElements accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.ignoreElements(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.ignoreElements.nullable', () { + nullableTest( + (s) => s.ignoreElements(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/interval_test.dart b/sandbox/reactivex/test/transformers/interval_test.dart new file mode 100644 index 0000000..0fa9315 --- /dev/null +++ b/sandbox/reactivex/test/transformers/interval_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [0, 1, 2, 3, 4]); + +void main() { + test('Rx.interval', () async { + const expectedOutput = [0, 1, 2, 3, 4]; + var count = 0, lastInterval = -1; + final stopwatch = Stopwatch()..start(); + + _getStream().interval(const Duration(milliseconds: 1)).listen( + expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (lastInterval != -1) { + expect(stopwatch.elapsedMilliseconds - lastInterval >= 1, true); + } + + lastInterval = stopwatch.elapsedMilliseconds; + }, count: expectedOutput.length), + onDone: stopwatch.stop); + }); + + test('Rx.interval.reusable', () async { + final transformer = + IntervalStreamTransformer(const Duration(milliseconds: 1)); + const expectedOutput = [0, 1, 2, 3, 4]; + var countA = 0, countB = 0; + final stopwatch = Stopwatch()..start(); + + _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length), + onDone: stopwatch.stop); + + _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length), + onDone: stopwatch.stop); + }); + + test('Rx.interval.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .interval(const Duration(milliseconds: 20)); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.interval.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .interval(const Duration(milliseconds: 20)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.interval accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.interval(const Duration(milliseconds: 10)); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.interval.nullable', () { + nullableTest( + (s) => s.interval(Duration.zero), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/join_test.dart b/sandbox/reactivex/test/transformers/join_test.dart new file mode 100644 index 0000000..008d2b5 --- /dev/null +++ b/sandbox/reactivex/test/transformers/join_test.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.join', () async { + final joined = await Stream.fromIterable(const ['h', 'i']).join('+'); + + await expectLater(joined, 'h+i'); + }); +} diff --git a/sandbox/reactivex/test/transformers/map_not_null_test.dart b/sandbox/reactivex/test/transformers/map_not_null_test.dart new file mode 100644 index 0000000..7f900be --- /dev/null +++ b/sandbox/reactivex/test/transformers/map_not_null_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.mapNotNull', () { + expect( + Stream.fromIterable(['1', '2', 'invalid_num', '3', 'invalid_num', '4']) + .mapNotNull(int.tryParse), + emitsInOrder([1, 2, 3, 4])); + + // 0-----1-----2-----3-----...-----8-----9-----| + // 1-----null--3-----null--...-----9-----null--| + // 1--3--5--7--9--| + final stream = Stream.periodic(const Duration(milliseconds: 10), (i) => i) + .take(10) + .transform(MapNotNullStreamTransformer((i) => i.isOdd ? null : i + 1)); + expect(stream, emitsInOrder([1, 3, 5, 7, 9, emitsDone])); + }); + + test('Rx.mapNotNull.shouldThrowA', () { + expect( + Stream.error(Exception()).mapNotNull((_) => true), + emitsError(isA()), + ); + + expect( + Rx.concat([ + Stream.fromIterable([1, 2]), + Stream.error(Exception()), + Stream.value(3), + ]).mapNotNull((i) => i.isEven ? i + 1 : null), + emitsInOrder([ + 3, + emitsError(isException), + emitsDone, + ]), + ); + }); + + test('Rx.mapNotNull.shouldThrowB', () { + expect( + Stream.fromIterable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).mapNotNull((i) { + if (i == 4) throw Exception(); + return i.isEven ? i + 1 : null; + }), + emitsInOrder([ + 3, + emitsError(isException), + 7, + 9, + 11, + emitsDone, + ]), + ); + }); + + test('Rx.mapNotNull.asBroadcastStream', () { + final stream = Stream.fromIterable([2, 3, 4, 5, 6]) + .mapNotNull((i) => null) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + }); + + test('Rx.mapNotNull.singleSubscription', () { + final stream = StreamController().stream.mapNotNull((i) => i); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.mapNotNull.pause.resume', () async { + final subscription = + Stream.fromIterable([2, 3, 4, 5, 6]).mapNotNull((i) => i).listen(null); + + subscription + ..pause() + ..onData(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })) + ..resume(); + }); + + test('Rx.mapNotNull.nullable', () { + nullableTest( + (s) => s.mapNotNull((i) => i), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/map_to_test.dart b/sandbox/reactivex/test/transformers/map_to_test.dart new file mode 100644 index 0000000..6e7febf --- /dev/null +++ b/sandbox/reactivex/test/transformers/map_to_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.mapTo', () async { + await expectLater(Rx.range(1, 4).mapTo(true), + emitsInOrder([true, true, true, true, emitsDone])); + }); + + test('Rx.mapTo.shouldThrow', () async { + await expectLater( + Rx.range(1, 4).concatWith([Stream.error(Error())]).mapTo(true), + emitsInOrder([ + true, + true, + true, + true, + emitsError(TypeMatcher()), + emitsDone + ])); + }); + + test('Rx.mapTo.reusable', () async { + final transformer = MapToStreamTransformer(true); + final stream = Rx.range(1, 4).asBroadcastStream(); + + stream.transform(transformer).listen(null); + stream.transform(transformer).listen(null); + + await expectLater(true, true); + }); + + test('Rx.mapTo.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(1).mapTo(true); + + subscription = stream.listen(expectAsync1((value) { + expect(value, isTrue); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.mapTo accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.mapTo(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.mapTo.nullable', () { + nullableTest( + (s) => s.mapTo('String'), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/materialize_test.dart b/sandbox/reactivex/test/transformers/materialize_test.dart new file mode 100644 index 0000000..bcb81c4 --- /dev/null +++ b/sandbox/reactivex/test/transformers/materialize_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.materialize.happyPath', () async { + final stream = Stream.value(1); + final notifications = >[]; + + stream.materialize().listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('Rx.materialize.reusable', () async { + final transformer = MaterializeStreamTransformer(); + final stream = Stream.value(1).asBroadcastStream(); + final notificationsA = >[], + notificationsB = >[]; + + stream.transform(transformer).listen(notificationsA.add, + onDone: expectAsync0(() { + expect(notificationsA, + [StreamNotification.data(1), StreamNotification.done()]); + })); + + stream.transform(transformer).listen(notificationsB.add, + onDone: expectAsync0(() { + expect(notificationsB, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('materializeTransformer.happyPath', () async { + final stream = Stream.fromIterable(const [1]); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('materializeTransformer.sadPath', () async { + final stream = Stream.error(Exception()); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, + onError: expectAsync2((Exception e, StackTrace s) { + // Check to ensure the stream does not come to this point + expect(true, isFalse); + }, count: 0), onDone: expectAsync0(() { + expect(notifications.length, 2); + expect(notifications[0].isError, isTrue); + expect(notifications[1].isDone, isTrue); + })); + }); + + test('materializeTransformer.onPause.onResume', () async { + final stream = Stream.fromIterable(const [1]); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, >[ + StreamNotification.data(1), + StreamNotification.done() + ]); + })) + ..pause() + ..resume(); + }); + + test('Rx.materialize accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.materialize(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.materialize.nullable', () { + nullableTest>( + (s) => s.materialize(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/max_test.dart b/sandbox/reactivex/test/transformers/max_test.dart new file mode 100644 index 0000000..cacd395 --- /dev/null +++ b/sandbox/reactivex/test/transformers/max_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.max', () async { + await expectLater(_getStream().max(), completion(9)); + + expect( + await Stream.fromIterable([1, 2, 3, 3.5]).max(), + 3.5, + ); + }); + + test('Rx.max.empty.shouldThrow', () { + expect( + () => Stream.empty().max(), + throwsStateError, + ); + }); + + test('Rx.max.error.shouldThrow', () { + expect( + () => Stream.value(1).concatWith( + [Stream.error(Exception('This is exception'))], + ).max(), + throwsException, + ); + }); + + test('Rx.max.with.comparator', () async { + await expectLater( + Stream.fromIterable(['one', 'two', 'three']) + .max((a, b) => a.length - b.length), + completion('three'), + ); + }); + + test('Rx.max.errorComparator.shouldThrow', () { + expect( + () => _getStream().max((a, b) => throw Exception()), + throwsException, + ); + }); + + test('Rx.max.without.comparator.Comparable', () async { + const expected = _Class2(3); + expect( + await Stream.fromIterable(const [ + _Class2(0), + expected, + _Class2(2), + _Class2(-1), + _Class2(2), + ]).max(), + expected, + ); + }); + + test('Rx.max.without.comparator.not.Comparable', () async { + expect( + () => Stream.fromIterable(const [ + _Class1(0), + _Class1(3), + _Class1(2), + _Class1(3), + _Class1(2), + ]).max(), + throwsStateError, + ); + }); +} + +class ErrorComparator implements Comparable { + @override + int compareTo(ErrorComparator other) { + throw Exception(); + } +} + +Stream _getStream() => + Stream.fromIterable(const [2, 3, 3, 5, 2, 9, 1, 2, 0]); + +class _Class1 { + final int value; + + const _Class1(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class1 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => '_Class{value: $value}'; +} + +class _Class2 implements Comparable<_Class2> { + final int value; + + const _Class2(this.value); + + @override + String toString() => '_Class2{value: $value}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class2 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + int compareTo(_Class2 other) => value.compareTo(other.value); +} diff --git a/sandbox/reactivex/test/transformers/merge_with_test.dart b/sandbox/reactivex/test/transformers/merge_with_test.dart new file mode 100644 index 0000000..ed6efdb --- /dev/null +++ b/sandbox/reactivex/test/transformers/merge_with_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.mergeWith', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + const expected = [2, 1]; + var count = 0; + + delayedStream.mergeWith([immediateStream]).listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.mergeWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.mergeWith([Stream.empty()]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.mergeWith on single stream should stay single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + final expected = [2, 1, emitsDone]; + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isFalse); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.mergeWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [2, 1, emitsDone]; + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.mergeWith multiple subscriptions on single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(() => concatenatedStream.listen(null), returnsNormally); + expect(() => concatenatedStream.listen(null), + throwsA(TypeMatcher())); + }); +} diff --git a/sandbox/reactivex/test/transformers/min_test.dart b/sandbox/reactivex/test/transformers/min_test.dart new file mode 100644 index 0000000..6c2c772 --- /dev/null +++ b/sandbox/reactivex/test/transformers/min_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.min', () async { + await expectLater(_getStream().min(), completion(0)); + + expect( + await Stream.fromIterable([1, 2, 3, 3.5]).min(), + 1, + ); + }); + + test('Rx.min.empty.shouldThrow', () { + expect( + () => Stream.empty().min(), + throwsStateError, + ); + }); + + test('Rx.min.error.shouldThrow', () { + expect( + () => Stream.value(1).concatWith( + [Stream.error(Exception('This is exception'))], + ).min(), + throwsException, + ); + }); + + test('Rx.min.errorComparator.shouldThrow', () { + expect( + () => _getStream().min((a, b) => throw Exception()), + throwsException, + ); + }); + + test('Rx.min.with.comparator', () async { + await expectLater( + Stream.fromIterable(['one', 'two', 'three']) + .min((a, b) => a.length - b.length), + completion('one'), + ); + }); + + test('Rx.min.without.comparator.Comparable', () async { + const expected = _Class2(-1); + expect( + await Stream.fromIterable(const [ + _Class2(0), + _Class2(3), + _Class2(2), + expected, + _Class2(2), + ]).min(), + expected, + ); + }); + + test('Rx.min.without.comparator.not.Comparable', () async { + expect( + () => Stream.fromIterable(const [ + _Class1(0), + _Class1(3), + _Class1(2), + _Class1(3), + _Class1(2), + ]).min(), + throwsStateError, + ); + }); +} + +Stream _getStream() => + Stream.fromIterable(const [2, 3, 3, 5, 2, 9, 1, 2, 0]); + +class _Class1 { + final int value; + + const _Class1(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class1 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => '_Class{value: $value}'; +} + +class _Class2 implements Comparable<_Class2> { + final int value; + + const _Class2(this.value); + + @override + String toString() => '_Class2{value: $value}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class2 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + int compareTo(_Class2 other) => value.compareTo(other.value); +} diff --git a/sandbox/reactivex/test/transformers/on_error_resume_test.dart b/sandbox/reactivex/test/transformers/on_error_resume_test.dart new file mode 100644 index 0000000..ce9253c --- /dev/null +++ b/sandbox/reactivex/test/transformers/on_error_resume_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [0, 1, 2, 3]); + +const List expected = [0, 1, 2, 3]; + +void main() { + test('Rx.onErrorResumeNext', () async { + var count = 0; + + Stream.error(Exception()) + .onErrorResumeNext(_getStream()) + .listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume', () async { + var count = 0; + + Stream.error(Exception()) + .onErrorResume((e, st) => _getStream()) + .listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume.correctError', () async { + final exception = Exception(); + + expect( + Stream.error(exception).onErrorResume((e, st) => Stream.value(e)), + emits(exception), + ); + }); + + test('Rx.onErrorResumeNext.asBroadcastStream', () async { + final stream = Stream.error(Exception()) + .onErrorResumeNext(_getStream()) + .asBroadcastStream(); + var countA = 0, countB = 0; + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((result) { + expect(result, expected[countA++]); + }, count: expected.length)); + stream.listen(expectAsync1((result) { + expect(result, expected[countB++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResumeNext.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()) + .onErrorResumeNext(Stream.error(Exception())); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.onErrorResumeNext.pause.resume', () async { + final transformer = + OnErrorResumeStreamTransformer((_, __) => _getStream()); + final exp = const [50] + expected; + late StreamSubscription subscription; + var count = 0; + + subscription = Rx.merge([ + Stream.value(50), + Stream.error(Exception()), + ]).transform(transformer).listen(expectAsync1((result) { + expect(result, exp[count++]); + + if (count == exp.length) { + subscription.cancel(); + } + }, count: exp.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorResumeNext.close', () async { + var count = 0; + + Stream.error(Exception()).onErrorResumeNext(_getStream()).listen( + expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length), + onDone: expectAsync0(() { + // The code should reach this point + expect(true, true); + }, count: 1)); + }); + + test('Rx.onErrorResumeNext.noErrors.close', () async { + expect( + Stream.empty().onErrorResumeNext(_getStream()), + emitsDone, + ); + }); + + test('OnErrorResumeStreamTransformer.reusable', () async { + final transformer = OnErrorResumeStreamTransformer( + (_, __) => _getStream().asBroadcastStream()); + var countA = 0, countB = 0; + + Stream.error(Exception()) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result, expected[countA++]); + }, count: expected.length)); + + Stream.error(Exception()) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result, expected[countB++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.onErrorResume((_, __) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorResumeNext accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorResumeNext(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorResume still adds data when Stream emits an error: issue/616', + () { + { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorResume((e, s) => Stream.value(-1)); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + } + + { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorResumeNext(Stream.value(-1)); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + } + }); + + test('Rx.onErrorResumeNext with many errors', () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.value(2), + Stream.error(StateError('')), + Stream.value(3), + ]).onErrorResume((e, s) { + if (e is Exception) { + return Rx.timer(-1, const Duration(milliseconds: 100)); + } + if (e is StateError) { + return Rx.timer(-2, const Duration(milliseconds: 200)); + } + throw e; + }); + expect( + stream, + emitsInOrder([1, 2, 3, -1, -2, emitsDone]), + ); + }); + + test('Rx.onErrorResumeNext.nullable', () { + nullableTest( + (s) => s.onErrorResumeNext(Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/on_error_return_test.dart b/sandbox/reactivex/test/transformers/on_error_return_test.dart new file mode 100644 index 0000000..d4d0644 --- /dev/null +++ b/sandbox/reactivex/test/transformers/on_error_return_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + const num expected = 0; + + test('Rx.onErrorReturn', () async { + Stream.error(Exception()) + .onErrorReturn(0) + .listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturn.asBroadcastStream', () async { + final stream = + Stream.error(Exception()).onErrorReturn(0).asBroadcastStream(); + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturn.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.error(Exception()) + .onErrorReturn(0) + .listen(expectAsync1((num result) { + expect(result, expected); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorReturn accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorReturn(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorReturn still adds data when Stream emits an error: issue/616', + () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorReturn(-1); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + }); + + test('Rx.onErrorReturn.nullable', () { + nullableTest( + (s) => s.onErrorReturn('String'), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/on_error_return_with_test.dart b/sandbox/reactivex/test/transformers/on_error_return_with_test.dart new file mode 100644 index 0000000..7ffc726 --- /dev/null +++ b/sandbox/reactivex/test/transformers/on_error_return_with_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + const num expected = 0; + + test('Rx.onErrorReturnWith', () async { + Stream.error(Exception()) + .onErrorReturnWith((e, _) => e is StateError ? 1 : 0) + .listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturnWith.asBroadcastStream', () async { + final stream = Stream.error(Exception()) + .onErrorReturnWith((_, __) => 0) + .asBroadcastStream(); + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturnWith.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.error(Exception()) + .onErrorReturnWith((_, __) => 0) + .listen(expectAsync1((num result) { + expect(result, expected); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorReturnWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorReturnWith((_, __) => 1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test( + 'Rx.onErrorReturnWith still adds data when Stream emits an error: issue/616', + () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorReturnWith((e, s) => -1); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + }); + + test('Rx.onErrorReturnWith.nullable', () { + nullableTest( + (s) => s.onErrorReturnWith((e, s) => 'String'), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/scan_test.dart b/sandbox/reactivex/test/transformers/scan_test.dart new file mode 100644 index 0000000..3913017 --- /dev/null +++ b/sandbox/reactivex/test/transformers/scan_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.scan', () async { + const expectedOutput = [1, 3, 6, 10]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => acc + value, 0) + .listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.scan.nullable', () { + nullableTest( + (s) => s.scan((acc, value, index) => acc, null), + ); + + expect( + Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => (acc ?? 0) + value, null) + .cast(), + emitsInOrder([1, 3, 6, 10]), + ); + }); + + test('Rx.scan.reusable', () async { + final transformer = + ScanStreamTransformer((acc, value, index) => acc + value, 0); + const expectedOutput = [1, 3, 6, 10]; + var countA = 0, countB = 0; + + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.scan.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .scan((acc, value, index) => acc + value, 0); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.scan.error.shouldThrow', () async { + final streamWithError = Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => throw StateError('oh noes!'), 0); + + streamWithError.listen(null, + onError: expectAsync2((StateError e, StackTrace s) { + expect(e, isStateError); + }, count: 4)); + }); + + test('Rx.scan accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.scan((acc, value, index) => acc + value, 0); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/sandbox/reactivex/test/transformers/skip_last_test.dart b/sandbox/reactivex/test/transformers/skip_last_test.dart new file mode 100644 index 0000000..6c5349c --- /dev/null +++ b/sandbox/reactivex/test/transformers/skip_last_test.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.skipLast', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(3); + await expectLater( + stream, + emitsInOrder([1, 2, emitsDone]), + ); + }); + + test('Rx.skipLast.zero', () async { + var count = 0; + final values = [1, 2, 3, 4, 5]; + final stream = + Stream.fromIterable(values).doOnData((_) => count++).skipLast(0); + await expectLater( + stream, + emitsInOrder([1, 2, 3, 4, 5, emitsDone]), + ); + expect(count, equals(values.length)); + }); + + test('Rx.skipLast.skipMoreThanLength', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(100); + + await expectLater( + stream, + emits(emitsDone), + ); + }); + + test('Rx.skipLast.emitsError', () async { + final stream = Stream.error(Exception()).skipLast(3); + await expectLater(stream, emitsError(isException)); + }); + + test('Rx.skipLast.countCantBeNegative', () async { + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(-1); + expect(stream, throwsA(isArgumentError)); + }); + + test('Rx.skipLast.reusable', () async { + final transformer = SkipLastStreamTransformer(1); + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(2); + var valueA = 1, valueB = 1; + + stream().transform(transformer).listen(expectAsync1( + (result) { + expect(result, valueA++); + }, + count: 2, + )); + + stream().transform(transformer).listen(expectAsync1( + (result) { + expect(result, valueB++); + }, + count: 2, + )); + }); + + test('Rx.skipLast.asBroadcastStream', () async { + final stream = + Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(3).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.skipLast.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4, 5]) + .skipLast(3) + .listen(expectAsync1((data) { + expect(data, 1); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.skipLast.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream.skipLast(3); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.skipLast.nullable', () { + nullableTest( + (s) => s.skipLast(1), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/skip_until_test.dart b/sandbox/reactivex/test/transformers/skip_until_test.dart new file mode 100644 index 0000000..fa3a97c --- /dev/null +++ b/sandbox/reactivex/test/transformers/skip_until_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 250), () { + controller.add(1); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.skipUntil', () async { + const expectedOutput = [3, 4]; + var count = 0; + + _getStream().skipUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.skipUntil.shouldClose', () async { + _getStream() + .skipUntil(Stream.empty()) + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + }); + + test('Rx.skipUntil.reusable', () async { + final transformer = SkipUntilStreamTransformer( + _getOtherStream().asBroadcastStream()); + const expectedOutput = [3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.skipUntil.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .skipUntil(_getOtherStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.skipUntil.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).skipUntil(_getOtherStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.skipUntil.error.shouldThrowB', () async { + final streamWithError = + Stream.value(1).skipUntil(Stream.error(Exception('Oh noes!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.skipUntil.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [3, 4]; + var count = 0; + + subscription = + _getStream().skipUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.skipUntil accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.skipUntil(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.skipUntil.nullable', () { + nullableTest( + (s) => s.skipUntil(Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/start_with_error_test.dart b/sandbox/reactivex/test/transformers/start_with_error_test.dart new file mode 100644 index 0000000..7a61c09 --- /dev/null +++ b/sandbox/reactivex/test/transformers/start_with_error_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:test/test.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWithError', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + }); + + test('Rx.startWithError.reusable', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + }); + + test('Rx.startWithError.asBroadcastStream', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final stream = _getStream().asBroadcastStream().transform(transformer); + const expectedOutput = [1, 2, 3, 4]; + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder( + [emitsError(isException), ...expectedOutput, emitsDone])); + await expectLater(stream, emitsDone); + }); + + test('Rx.startWithError.error.shouldThrow', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final streamWithError = + Stream.error(Exception()).transform(transformer); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.startWithError.pause.resume', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length), + onError: (Object e, StackTrace s) => expect(e, isException)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.startWithError accidental broadcast', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final controller = StreamController(); + + final stream = controller.stream.transform(transformer); + + stream.listen(null, onError: (Object e, StackTrace s) {}); + expect(() => stream.listen(null, onError: (Object e, StackTrace s) {}), + throwsStateError); + + controller.add(1); + }); +} diff --git a/sandbox/reactivex/test/transformers/start_with_many_test.dart b/sandbox/reactivex/test/transformers/start_with_many_test.dart new file mode 100644 index 0000000..7159e3b --- /dev/null +++ b/sandbox/reactivex/test/transformers/start_with_many_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWithMany', () async { + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var count = 0; + + _getStream().startWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWithMany.reusable', () async { + final transformer = StartWithManyStreamTransformer(const [5, 6]); + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWithMany.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().startWithMany(const [5, 6]); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.startWithMany.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).startWithMany(const [5, 6]); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.startWithMany.pause.resume', () async { + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = + _getStream().startWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.startWithMany accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.startWithMany(const [1, 2, 3]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.startWithMany.nullable', () { + nullableTest( + (s) => s.startWithMany([]), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/start_with_test.dart b/sandbox/reactivex/test/transformers/start_with_test.dart new file mode 100644 index 0000000..235b6ff --- /dev/null +++ b/sandbox/reactivex/test/transformers/start_with_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWith', () async { + const expectedOutput = [5, 1, 2, 3, 4]; + var count = 0; + + _getStream().startWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWith.reusable', () async { + final transformer = StartWithStreamTransformer(5); + const expectedOutput = [5, 1, 2, 3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWith.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().startWith(5); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.startWith.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).startWith(5); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.startWith.pause.resume', () async { + const expectedOutput = [5, 1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().startWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.startWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.startWith(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test( + 'Rx.startWith broadcast stream should not startWith on multiple subscribers', + () async { + final controller = StreamController.broadcast(); + + final stream = controller.stream.startWith(1); + + await controller.close(); + + stream.listen(null); + + await Future.delayed(const Duration(milliseconds: 10)); + + await expectLater(stream, emits(emitsDone)); + }, skip: true); + + test('Rx.startWith.nullable', () { + nullableTest( + (s) => s.startWith('String'), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/switch_if_empty_test.dart b/sandbox/reactivex/test/transformers/switch_if_empty_test.dart new file mode 100644 index 0000000..c1f15be --- /dev/null +++ b/sandbox/reactivex/test/transformers/switch_if_empty_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.switchIfEmpty.whenEmpty', () async { + expect( + Stream.empty().switchIfEmpty(Stream.value(1)), + emitsInOrder([1, emitsDone]), + ); + }); + + test('Rx.initial.completes', () async { + expect( + Stream.value(99).switchIfEmpty(Stream.value(1)), + emitsInOrder([99, emitsDone]), + ); + }); + + test('Rx.switchIfEmpty.reusable', () async { + final transformer = SwitchIfEmptyStreamTransformer( + Stream.value(true).asBroadcastStream()); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.switchIfEmpty.whenNotEmpty', () async { + Stream.value(false) + .switchIfEmpty(Stream.value(true)) + .listen(expectAsync1((result) { + expect(result, false); + }, count: 1)); + }); + + test('Rx.switchIfEmpty.asBroadcastStream', () async { + final stream = + Stream.empty().switchIfEmpty(Stream.value(1)).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.switchIfEmpty.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).switchIfEmpty(Stream.value(1)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchIfEmpty.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.empty().switchIfEmpty(Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.switchIfEmpty accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.switchIfEmpty(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.switchIfEmpty.nullable', () { + nullableTest( + (s) => s.switchIfEmpty(Stream.value('String')), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/switch_map_test.dart b/sandbox/reactivex/test/transformers/switch_map_test.dart new file mode 100644 index 0000000..cb86396 --- /dev/null +++ b/sandbox/reactivex/test/transformers/switch_map_test.dart @@ -0,0 +1,359 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 10), () => controller.add(1)); + Timer(const Duration(milliseconds: 20), () => controller.add(2)); + Timer(const Duration(milliseconds: 30), () => controller.add(3)); + Timer(const Duration(milliseconds: 40), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream(int value) { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 15), () => controller.add(value + 1)); + Timer(const Duration(milliseconds: 25), () => controller.add(value + 2)); + Timer(const Duration(milliseconds: 35), () => controller.add(value + 3)); + Timer(const Duration(milliseconds: 45), () { + controller.add(value + 4); + controller.close(); + }); + + return controller.stream; +} + +Stream range() => + Stream.fromIterable(const [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + +void main() { + test('Rx.switchMap', () async { + const expectedOutput = [5, 6, 7, 8]; + var count = 0; + + _getStream().switchMap(_getOtherStream).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.switchMap.reusable', () async { + final transformer = SwitchMapStreamTransformer(_getOtherStream); + const expectedOutput = [5, 6, 7, 8]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.switchMap.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().switchMap(_getOtherStream); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.switchMap.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).switchMap(_getOtherStream); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.error.shouldThrowB', () async { + final streamWithError = Stream.value(1).switchMap( + (_) => Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.error.shouldThrowC', () async { + final streamWithError = Stream.value(1).switchMap((_) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(0).switchMap((_) => Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.switchMap stream close after switch', () async { + final controller = StreamController(); + final list = controller.stream + .switchMap((it) => Stream.fromIterable([it, it])) + .toList(); + + controller.add(1); + await Future.delayed(Duration(microseconds: 1)); + controller.add(2); + + await controller.close(); + expect(await list, [1, 1, 2, 2]); + }); + + test('Rx.switchMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.switchMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.switchMap closes after the last inner Stream closed - issue/511', + () async { + final outer = StreamController(); + final inner = BehaviorSubject.seeded(false); + final stream = outer.stream.switchMap((_) => inner.stream); + + expect(stream, emitsThrough(emitsDone)); + + outer.add(true); + await Future.delayed(Duration.zero); + await inner.close(); + await outer.close(); + }); + + test('Rx.switchMap every subscription triggers a listen on the root Stream', + () async { + var count = 0; + final controller = StreamController.broadcast(); + final root = + OnSubscriptionTriggerableStream(controller.stream, () => count++); + final stream = root.switchMap((event) => Stream.value(event)); + + stream.listen((event) {}); + stream.listen((event) {}); + + expect(count, 2); + + await controller.close(); + }); + + test('Rx.switchMap.nullable', () { + nullableTest( + (s) => s.switchMap((v) => Stream.value(v)), + ); + }); + + test( + 'Rx.switchMap pauses subscription when cancelling inner subscription, then resume', + () async { + var isController1Cancelled = false; + final cancelCompleter1 = Completer.sync(); + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + await cancelCompleter1.future; + isController1Cancelled = true; + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + + await Future.delayed(const Duration(milliseconds: 10)); + cancelCompleter1.complete(null); + } + }, + count: 4, + ), + ); + }, + ); + + test('Rx.switchMap forwards errors from the cancel()', () { + var isController1Cancelled = false; + + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + isController1Cancelled = true; + throw Exception('cancel error'); + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + } + }, + count: 4, + ), + onError: expectAsync1( + (Object error) => expect(error, isException), + count: 1, + ), + ); + }); + + test( + 'Rx.switchMap pauses the next inner StreamSubscription when pausing while cancelling the previous inner Stream', + () { + var isController1Cancelled = false; + final cancelCompleter1 = Completer.sync(); + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + await cancelCompleter1.future; + isController1Cancelled = true; + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + late StreamSubscription subscription; + subscription = stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + + await Future.delayed(const Duration(milliseconds: 10)); + + // pauses the subscription while cancelling the controller1 + subscription.pause(); + + // let the cancellation of controller1 complete + cancelCompleter1.complete(null); + + // make sure the controller2.stream is added to the controller + await pumpEventQueue(); + + // controller2.stream should be paused + expect(controller2.isPaused, true); + + // resume the subscription to continue the rest of the stream + subscription.resume(); + } + }, + count: 4, + ), + ); + }, + ); +} + +class OnSubscriptionTriggerableStream extends Stream { + final Stream inner; + final void Function() onSubscribe; + + OnSubscriptionTriggerableStream(this.inner, this.onSubscribe); + + @override + bool get isBroadcast => inner.isBroadcast; + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + onSubscribe(); + return inner.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } +} diff --git a/sandbox/reactivex/test/transformers/take_last_test.dart b/sandbox/reactivex/test/transformers/take_last_test.dart new file mode 100644 index 0000000..64474c6 --- /dev/null +++ b/sandbox/reactivex/test/transformers/take_last_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.takeLast', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3); + await expectLater( + stream, + emitsInOrder([3, 4, 5, emitsDone]), + ); + }); + + test('Rx.takeLast.zero', () async { + var count = 0; + final values = [1, 2, 3, 4, 5]; + final stream = + Stream.fromIterable(values).doOnData((_) => count++).takeLast(0); + await expectLater( + stream, + emitsInOrder([emitsDone]), + ); + expect(count, equals(values.length)); + }); + + test('Rx.takeLast.emitsError', () async { + final stream = Stream.error(Exception()).takeLast(3); + await expectLater(stream, emitsError(isException)); + }); + + test('Rx.takeLast.countCantBeNegative', () async { + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(-1); + expect(stream, throwsA(isArgumentError)); + }); + + test('Rx.takeLast.reusable', () async { + final transformer = TakeLastStreamTransformer(3); + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3); + var valueA = 3, valueB = 3; + + stream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueA++); + }, count: 3)); + + stream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueB++); + }, count: 3)); + }); + + test('Rx.takeLast.asBroadcastStream', () async { + final stream = + Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.takeLast.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4, 5]) + .takeLast(3) + .listen(expectAsync1((data) { + expect(data, 3); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeLast.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream.takeLast(3); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeLast.cancel', () { + final subscription = + Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3).listen(null); + subscription.onData( + expectAsync1( + (event) { + subscription.cancel(); + expect(event, 3); + }, + count: 1, + ), + ); + }, timeout: const Timeout(Duration(seconds: 1))); + + test('Rx.takeLast.nullable', () { + nullableTest( + (s) => s.takeLast(1), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/take_until_test.dart b/sandbox/reactivex/test/transformers/take_until_test.dart new file mode 100644 index 0000000..23efaf2 --- /dev/null +++ b/sandbox/reactivex/test/transformers/take_until_test.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 250), () { + controller.add(1); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.takeUntil', () async { + const expectedOutput = [1, 2]; + var count = 0; + + _getStream().takeUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.takeUntil.shouldClose', () async { + _getStream() + .takeUntil(Stream.empty()) + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + }); + + test('Rx.takeUntil.reusable', () async { + final transformer = TakeUntilStreamTransformer( + _getOtherStream().asBroadcastStream()); + const expectedOutput = [1, 2]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.takeUntil.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .takeUntil(_getOtherStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.takeUntil.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).takeUntil(_getOtherStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.takeUntil.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [1, 2]; + var count = 0; + + subscription = + _getStream().takeUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeUntil accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.takeUntil(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeUntil.nullable', () { + nullableTest( + (s) => s.takeUntil(Stream.empty()), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/take_while_inclusive_test.dart b/sandbox/reactivex/test/transformers/take_while_inclusive_test.dart new file mode 100644 index 0000000..7b2f774 --- /dev/null +++ b/sandbox/reactivex/test/transformers/take_while_inclusive_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.takeWhileInclusive', () async { + final stream = Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) + .takeWhileInclusive((i) => i < 4); + await expectLater( + stream, + emitsInOrder([2, 3, 4, emitsDone]), + ); + }); + + test('Rx.takeWhileInclusive.shouldClose', () async { + final stream = + Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]).takeWhileInclusive((i) { + if (i == 4) { + throw Exception(); + } else { + return true; + } + }); + await expectLater( + stream, + emitsInOrder( + [ + 2, + 3, + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('Rx.takeWhileInclusive.asBroadcastStream', () async { + final stream = Stream.fromIterable([2, 3, 4, 5, 6]) + .takeWhileInclusive((i) => i < 4) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(true, true); + }); + + test('Rx.takeWhileInclusive.shouldThrowB', () async { + final stream = + Stream.error(Exception()).takeWhileInclusive((_) => true); + await expectLater( + stream, + emitsError(isA()), + ); + }); + + test('Rx.takeWhileInclusive.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([2, 3, 4, 5, 6]) + .takeWhileInclusive((i) => i < 4) + .listen(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeWhileInclusive accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.takeWhileInclusive((_) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeWhileInclusive.nullable', () { + nullableTest( + (s) => s.takeWhileInclusive((_) => true), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/time_interval_test.dart b/sandbox/reactivex/test/transformers/time_interval_test.dart new file mode 100644 index 0000000..9c1d1f4 --- /dev/null +++ b/sandbox/reactivex/test/transformers/time_interval_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable([0, 1, 2]); + +void main() { + test('Rx.timeInterval', () async { + const expectedOutput = [0, 1, 2]; + var count = 0; + + _getStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval() + .listen(expectAsync1((result) { + expect(expectedOutput[count++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + }); + + test('Rx.timeInterval.reusable', () async { + final transformer = TimeIntervalStreamTransformer(); + const expectedOutput = [0, 1, 2]; + var countA = 0, countB = 0; + + _getStream() + .interval(const Duration(milliseconds: 1)) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countA++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + + _getStream() + .interval(const Duration(milliseconds: 1)) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countB++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + }); + + test('Rx.timeInterval.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.timeInterval.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()) + .interval(const Duration(milliseconds: 1)) + .timeInterval(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.timeInterval.pause.resume', () async { + late StreamSubscription> subscription; + const expectedOutput = [0, 1, 2]; + var count = 0; + + subscription = _getStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval() + .listen(expectAsync1((result) { + expect(result.value, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.timeInterval accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.timeInterval(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.timeInterval.nullable', () { + nullableTest>( + (s) => s.timeInterval(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/timeout_test.dart b/sandbox/reactivex/test/transformers/timeout_test.dart new file mode 100644 index 0000000..5460b1d --- /dev/null +++ b/sandbox/reactivex/test/transformers/timeout_test.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.timeout', () async { + late StreamSubscription subscription; + + final stream = Stream.fromFuture( + Future.delayed(Duration(milliseconds: 30), () => 1)) + .timeout(Duration(milliseconds: 1)); + + subscription = stream.listen((_) {}, + onError: expectAsync2((Object e, StackTrace s) { + expect(e is TimeoutException, isTrue); + subscription.cancel(); + }, count: 1)); + }); +} diff --git a/sandbox/reactivex/test/transformers/timestamp_test.dart b/sandbox/reactivex/test/transformers/timestamp_test.dart new file mode 100644 index 0000000..0a4cccf --- /dev/null +++ b/sandbox/reactivex/test/transformers/timestamp_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.Rx.timestamp', () async { + const expected = [1, 2, 3]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3]) + .timestamp() + .listen(expectAsync1((result) { + expect(result.value, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.Rx.timestamp.reusable', () async { + final transformer = TimestampStreamTransformer(); + const expected = [1, 2, 3]; + var countA = 0, countB = 0; + + Stream.fromIterable(const [1, 2, 3]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result.value, expected[countA++]); + }, count: expected.length)); + + Stream.fromIterable(const [1, 2, 3]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result.value, expected[countB++]); + }, count: expected.length)); + }); + + test('timestampTransformer', () async { + const expected = [1, 2, 3]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()) + .listen(expectAsync1((result) { + expect(result.value, expected[count++]); + }, count: expected.length)); + }); + + test('timestampTransformer.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('timestampTransformer.error.shouldThrow', () async { + final streamWithError = + Stream.error(Exception()).transform(TimestampStreamTransformer()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('timestampTransformer.pause.resume', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()); + const expected = [1, 2, 3]; + late StreamSubscription> subscription; + var count = 0; + + subscription = stream.listen(expectAsync1((result) { + expect(result.value, expected[count++]); + + if (count == expected.length) { + subscription.cancel(); + } + }, count: expected.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.timestamp accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.timestamp(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.timestamp.nullable', () { + nullableTest>( + (s) => s.timestamp(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/where_not_null_test.dart b/sandbox/reactivex/test/transformers/where_not_null_test.dart new file mode 100644 index 0000000..fd9c77e --- /dev/null +++ b/sandbox/reactivex/test/transformers/where_not_null_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.whereNotNull', () { + { + final notNull = Stream.fromIterable([1, 2, 3, 4]).whereNotNull(); + + expect(notNull, isA>()); + expect(notNull, emitsInOrder([1, 2, 3, 4])); + } + + { + final notNull = Stream.fromIterable([1, 2, null, 3, 4, null]) + .transform(WhereNotNullStreamTransformer()); + + expect(notNull, isA>()); + expect(notNull, emitsInOrder([1, 2, 3, 4])); + } + }); + + test('Rx.whereNotNull.shouldThrow', () { + expect( + Stream.error(Exception()).whereNotNull(), + emitsError(isA()), + ); + + expect( + Rx.concat([ + Stream.fromIterable([1, 2, null]), + Stream.error(Exception()), + Stream.value(3), + ]).whereNotNull(), + emitsInOrder([ + 1, + 2, + emitsError(isException), + 3, + emitsDone, + ]), + ); + }); + + test('Rx.whereNotNull.asBroadcastStream', () { + final stream = + Stream.fromIterable([1, 2, null]).whereNotNull().asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + }); + + test('Rx.whereNotNull.singleSubscription', () { + final stream = StreamController().stream.whereNotNull(); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.whereNotNull.pause.resume', () async { + final subscription = Stream.fromIterable([null, 2, 3, null, 4, 5, 6]) + .whereNotNull() + .listen(null); + + subscription + ..pause() + ..onData(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })) + ..resume(); + }); + + test('Rx.whereNotNull.nullable', () { + nullableTest( + (s) => s.whereNotNull(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/where_type_test.dart b/sandbox/reactivex/test/transformers/where_type_test.dart new file mode 100644 index 0000000..2eb0158 --- /dev/null +++ b/sandbox/reactivex/test/transformers/where_type_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add('2')); + Timer( + const Duration(milliseconds: 300), () => controller.add(const {'3': 3})); + Timer(const Duration(milliseconds: 400), () { + controller.add(const {'4': '4'}); + }); + Timer(const Duration(milliseconds: 500), () { + controller.add(5.0); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.whereType', () async { + _getStream().whereType>().listen(expectAsync1((result) { + expect(result, isMap); + }, count: 1)); + }); + + test('Rx.whereType.polymorphism', () async { + _getStream().whereType().listen(expectAsync1((Object result) { + expect(result is num, true); + }, count: 2)); + }); + + test('Rx.whereType.null.values', () async { + await expectLater( + Stream.fromIterable([null, 1, null, 'two', 3]).whereType(), + emitsInOrder(const ['two'])); + }); + + test('Rx.whereType.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().whereType(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.whereType.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).whereType(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.whereType.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(1).whereType(); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.whereType accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.whereType(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.whereType.nullable', () { + nullableTest( + (s) => s.whereType(), + ); + }); +} diff --git a/sandbox/reactivex/test/transformers/with_latest_from_test.dart b/sandbox/reactivex/test/transformers/with_latest_from_test.dart new file mode 100644 index 0000000..5191440 --- /dev/null +++ b/sandbox/reactivex/test/transformers/with_latest_from_test.dart @@ -0,0 +1,541 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +/// creates 5 Streams, deferred from a source Stream, so that they all emit +/// under the same Timer interval. +/// before, tests could fail, since we created 5 separate Streams with each +/// using their own Timer. +List> _createTestStreams() { + /// creates streams that emit after a certain amount of milliseconds, + /// the List of intervals (in ms) + const intervals = [22, 50, 30, 40, 60]; + final ticker = + Stream.periodic(const Duration(milliseconds: 1), (index) => index) + .skip(1) + .take(300) + .asBroadcastStream(); + + return [ + ticker + .where((index) => index % intervals[0] == 0) + .map((index) => index ~/ intervals[0] - 1), + ticker + .where((index) => index % intervals[1] == 0) + .map((index) => index ~/ intervals[1] - 1), + ticker + .where((index) => index % intervals[2] == 0) + .map((index) => index ~/ intervals[2] - 1), + ticker + .where((index) => index % intervals[3] == 0) + .map((index) => index ~/ intervals[3] - 1), + ticker + .where((index) => index % intervals[4] == 0) + .map((index) => index ~/ intervals[4] - 1) + ]; +} + +void main() { + test('Rx.withLatestFrom', () async { + const expectedOutput = [ + Pair(2, 0), + Pair(3, 0), + Pair(4, 1), + Pair(5, 1), + Pair(6, 2) + ]; + final streams = _createTestStreams(); + + await expectLater( + streams.first + .withLatestFrom( + streams[1], (first, int second) => Pair(first, second)) + .take(5), + emitsInOrder(expectedOutput)); + }); + + test('Rx.withLatestFrom.iterate.once', () async { + var iterationCount = 0; + + final combined = Stream.value(1).withLatestFromList(() sync* { + ++iterationCount; + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + combined, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.withLatestFrom.reusable', () async { + final streams = _createTestStreams(); + final transformer = WithLatestFromStreamTransformer.with1( + streams[1], (first, second) => Pair(first, second)); + const expectedOutput = [ + Pair(2, 0), + Pair(3, 0), + Pair(4, 1), + Pair(5, 1), + Pair(6, 2) + ]; + var countA = 0, countB = 0; + + streams.first.transform(transformer).take(5).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + streams.first.transform(transformer).take(5).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.withLatestFrom.asBroadcastStream', () async { + final streams = _createTestStreams(); + final stream = + streams.first.withLatestFrom(streams[1], (first, int second) => 0); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + await expectLater(true, true); + }); + + test('Rx.withLatestFrom.error.shouldThrowA', () async { + final streams = _createTestStreams(); + final streamWithError = Stream.error(Exception()) + .withLatestFrom(streams[1], (first, int second) => 'Hello'); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.withLatestFrom.error.shouldThrowB', () async { + final streams = _createTestStreams(); + final stream = streams[1].take(1).withLatestFrom( + Stream.value(0), (first, int second) => throw Exception()); + + expect( + stream, + emitsInOrder([ + emitsError(isException), + emitsDone, + ])); + }); + + test('Rx.withLatestFrom.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [Pair(2, 0)]; + final streams = _createTestStreams(); + var count = 0; + + subscription = streams.first + .withLatestFrom(streams[1], (first, int second) => Pair(first, second)) + .take(1) + .listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.withLatestFrom.otherEmitsNull', () async { + const expected = Pair(1, null); + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom( + Stream.value(null), + (a, int? b) => Pair(a, b), + ); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom.otherNotEmit', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom( + Stream.empty(), + (a, int b) => Pair(a, b), + ); + + await expectLater( + stream, + emitsDone, + ); + }); + + test('Rx.withLatestFrom2', () async { + const expectedOutput = [ + _Tuple(2, 0, 1), + _Tuple(3, 0, 1), + _Tuple(4, 1, 2), + _Tuple(5, 1, 3), + _Tuple(6, 2, 4), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom2( + streams[1], + streams[2], + (item1, int item2, int item3) => _Tuple(item1, item2, item3), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom3', () async { + const expectedOutput = [ + _Tuple(2, 0, 1, 0), + _Tuple(3, 0, 1, 1), + _Tuple(4, 1, 2, 1), + _Tuple(5, 1, 3, 2), + _Tuple(6, 2, 4, 2), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom3( + streams[1], + streams[2], + streams[3], + (item1, int item2, int item3, int item4) => + _Tuple(item1, item2, item3, item4), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom4', () async { + const expectedOutput = [ + _Tuple(2, 0, 1, 0, 0), + _Tuple(3, 0, 1, 1, 0), + _Tuple(4, 1, 2, 1, 0), + _Tuple(5, 1, 3, 2, 1), + _Tuple(6, 2, 4, 2, 1), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom4( + streams[1], + streams[2], + streams[3], + streams[4], + (item1, int item2, int item3, int item4, int item5) => + _Tuple(item1, item2, item3, item4, item5), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom5', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom5( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + (a, int b, int c, int d, int e, int f) => _Tuple(a, b, c, d, e, f), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom6', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom6( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + (a, int b, int c, int d, int e, int f, int g) => + _Tuple(a, b, c, d, e, f, g), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom7', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom7( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + (a, int b, int c, int d, int e, int f, int g, int h) => + _Tuple(a, b, c, d, e, f, g, h), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom8', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom8( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + (a, int b, int c, int d, int e, int f, int g, int h, int i) => + _Tuple(a, b, c, d, e, f, g, h, i), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8, 9); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom9', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom9( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + Stream.value(10), + (a, int b, int c, int d, int e, int f, int g, int h, int i, int j) => + _Tuple(a, b, c, d, e, f, g, h, i, j), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFromList', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFromList( + [ + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + Stream.value(10), + ], + ); + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFromList.emptyList', () async { + final stream = Stream.fromIterable([1, 2, 3]).withLatestFromList([]); + + await expectLater( + stream, + emitsInOrder( + >[ + [1], + [2], + [3], + ], + ), + ); + }); + test('Rx.withLatestFrom accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream + .withLatestFrom(Stream.empty(), (_, dynamic __) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.withLatestFrom.nullable', () { + nullableTest>( + (s) => s.withLatestFromList([Stream.value('String')]), + ); + }); +} + +class Pair { + final int? first; + final int? second; + + const Pair(this.first, this.second); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is Pair && first == other.first && second == other.second; + } + + @override + int get hashCode { + return first.hashCode ^ second.hashCode; + } + + @override + String toString() { + return 'Pair{first: $first, second: $second}'; + } +} + +class _Tuple { + final int? item1; + final int? item2; + final int? item3; + final int? item4; + final int? item5; + final int? item6; + final int? item7; + final int? item8; + final int? item9; + final int? item10; + + const _Tuple([ + this.item1, + this.item2, + this.item3, + this.item4, + this.item5, + this.item6, + this.item7, + this.item8, + this.item9, + this.item10, + ]); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _Tuple && + item1 == other.item1 && + item2 == other.item2 && + item3 == other.item3 && + item4 == other.item4 && + item5 == other.item5 && + item6 == other.item6 && + item7 == other.item7 && + item8 == other.item8 && + item9 == other.item9 && + item10 == other.item10; + } + + @override + int get hashCode { + return item1.hashCode ^ + item2.hashCode ^ + item3.hashCode ^ + item4.hashCode ^ + item5.hashCode ^ + item6.hashCode ^ + item7.hashCode ^ + item8.hashCode ^ + item9.hashCode ^ + item10.hashCode; + } + + @override + String toString() { + final values = [ + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10, + ]; + final s = values.join(', '); + return 'Tuple { $s }'; + } +} diff --git a/sandbox/reactivex/test/transformers/zip_with_test.dart b/sandbox/reactivex/test/transformers/zip_with_test.dart new file mode 100644 index 0000000..36839c9 --- /dev/null +++ b/sandbox/reactivex/test/transformers/zip_with_test.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.zipWith', () async { + Stream.value(1) + .zipWith(Stream.value(2), (int one, int two) => one + two) + .listen(expectAsync1((int result) { + expect(result, 3); + }, count: 1)); + }); + + test('Rx.zipWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.zipWith(Stream.empty(), (_, dynamic __) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.zipWith on single stream should stay single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + final expected = [3, emitsDone]; + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(concatenatedStream.isBroadcast, isFalse); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.zipWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [3, emitsDone]; + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.zipWith multiple subscriptions on single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(() => concatenatedStream.listen(null), returnsNormally); + expect(() => concatenatedStream.listen(null), + throwsA(TypeMatcher())); + }); +} diff --git a/sandbox/reactivex/test/utils.dart b/sandbox/reactivex/test/utils.dart new file mode 100644 index 0000000..2a8f4fd --- /dev/null +++ b/sandbox/reactivex/test/utils.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// Explicitly ignores a future. +/// +/// Not all futures need to be awaited. +/// The Dart linter has an optional ["unawaited futures" lint](https://dart-lang.github.io/linter/lints/unawaited_futures.html) +/// which enforces that futures (expressions with a static type of [Future]) +/// in asynchronous functions are handled *somehow*. +/// If a particular future value doesn't need to be awaited, +/// you can call `unawaited(...)` with it, which will avoid the lint, +/// simply because the expression no longer has type [Future]. +/// Using `unawaited` has no other effect. +/// You should use `unawaited` to convey the *intention* of +/// deliberately not waiting for the future. +/// +/// If the future completes with an error, +/// it was likely a mistake to not await it. +/// That error will still occur and will be considered unhandled +/// unless the same future is awaited (or otherwise handled) elsewhere too. +/// Because of that, `unawaited` should only be used for futures that +/// are *expected* to complete with a value. +void unawaited(Future future) {} + +void nullableTest(Stream Function(Stream s) transform) => + transform(Stream.fromIterable(['1', '2', '3'])); diff --git a/sandbox/reactivex/test/utils/composite_subscription_test.dart b/sandbox/reactivex/test/utils/composite_subscription_test.dart new file mode 100644 index 0000000..66a01e3 --- /dev/null +++ b/sandbox/reactivex/test/utils/composite_subscription_test.dart @@ -0,0 +1,316 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('CompositeSubscription', () { + test('cast to StreamSubscription of any type', () { + final cs = CompositeSubscription(); + + expect(cs, isA>()); + // ignore: prefer_void_to_null + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + + cs as StreamSubscription; // ignore: unnecessary_cast + // ignore: unnecessary_cast, prefer_void_to_null + cs as StreamSubscription; + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + + expect(true, true); + }); + + group('throws UnsupportedError', () { + test('when calling asFuture()', () { + expect( + () => CompositeSubscription().asFuture(0), throwsUnsupportedError); + }); + + test('when calling onData()', () { + expect(() => CompositeSubscription().onData((_) {}), + throwsUnsupportedError); + }); + + test('when calling onError()', () { + expect(() => CompositeSubscription().onError((Object _) {}), + throwsUnsupportedError); + }); + + test('when calling onDone()', () { + expect(() => CompositeSubscription().onDone(() {}), + throwsUnsupportedError); + }); + }); + + group('Rx.compositeSubscription.clear', () { + test('should cancel all subscriptions', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.clear(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should return null since no subscription has been canceled clear()', + () { + final composite = CompositeSubscription(); + final done = composite.clear(); + expect(done, null); + }, + ); + }); + + group('Rx.compositeSubscription.onDispose', () { + test('should cancel all subscriptions when calling dispose()', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.dispose(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test('should cancel all subscriptions when calling cancel()', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.cancel(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should return null since no subscription has been canceled on dispose()', + () { + final composite = CompositeSubscription(); + final done = composite.dispose(); + expect(done, null); + }, + ); + + test( + 'should return Future completed with null since no subscription has been canceled on cancel()', + () { + final composite = CompositeSubscription(); + final done = composite.cancel(); + expect(done, completion(null)); + }, + ); + + test( + 'should throw exception if trying to add subscription to disposed composite, after calling dispose()', + () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite.dispose(); + + expect(() => composite.add(stream.listen(null)), throwsA(anything)); + }, + ); + + test( + 'should throw exception if trying to add subscription to disposed composite, after calling cancel()', + () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite.cancel(); + + expect(() => composite.add(stream.listen(null)), throwsA(anything)); + }, + ); + }); + + group('Rx.compositeSubscription.remove', () { + test('should cancel subscription on if it is removed from composite', () { + const value = 1; + final stream = Stream.fromIterable([value]).shareValue(); + final composite = CompositeSubscription(); + final subscription = stream.listen(null); + + composite.add(subscription); + final done = composite.remove(subscription); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should not cancel the subscription since it is not present in the composite', + () { + const value = 1; + final stream = Stream.fromIterable([value]).shareValue(); + final composite = CompositeSubscription(); + final subscription = stream.listen(null); + + final done = composite.remove(subscription); + + expect(stream, emits(anything)); + expect(done, null); + }, + ); + }); + + test('Rx.compositeSubscription.pauseAndResume()', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + composite.add(s1); + composite.add(s2); + + void expectPaused() { + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + expect(s1.isPaused, isTrue); + expect(s2.isPaused, isTrue); + } + + void expectResumed() { + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + expect(s1.isPaused, isFalse); + expect(s2.isPaused, isFalse); + } + + composite.pauseAll(); + + expectPaused(); + + composite.resumeAll(); + + expectResumed(); + + composite.pause(); + + expectPaused(); + + composite.resume(); + + expectResumed(); + }); + + test('Rx.compositeSubscription.resumeWithFuture', () async { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + final completer = Completer(); + + composite.add(s1); + composite.add(s2); + composite.pauseAll(completer.future); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + completer.complete(); + + await expectLater(completer.future.then((_) => composite.allPaused), + completion(isFalse)); + await expectLater(completer.future.then((_) => composite.isPaused), + completion(isFalse)); + }); + + test('Rx.compositeSubscription.allPaused', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + composite.add(s1); + composite.add(s2); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + composite.pauseAll(); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + composite.remove(s1); + composite.remove(s2); + + /// all subscriptions are removed, allPaused should yield false + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + }); + + test('Rx.compositeSubscription.allPaused.indirectly', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + s1.pause(); + s2.pause(); + + composite.add(s1); + composite.add(s2); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + s1.resume(); + s2.resume(); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + }); + + test('Rx.compositeSubscription.size', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + expect(composite.isEmpty, isTrue); + expect(composite.isNotEmpty, isFalse); + expect(composite.length, 0); + + composite.add(s1); + composite.add(s2); + + expect(composite.isEmpty, isFalse); + expect(composite.isNotEmpty, isTrue); + expect(composite.length, 2); + + composite.remove(s1); + composite.remove(s2); + + expect(composite.isEmpty, isTrue); + expect(composite.isNotEmpty, isFalse); + expect(composite.length, 0); + }); + }); +} diff --git a/sandbox/reactivex/test/utils/notification_test.dart b/sandbox/reactivex/test/utils/notification_test.dart new file mode 100644 index 0000000..45191f9 --- /dev/null +++ b/sandbox/reactivex/test/utils/notification_test.dart @@ -0,0 +1,234 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamNotification', () { + test('hashCode', () { + final value1 = 1; + final value2 = 2; + + final st1 = StackTrace.current; + final st2 = StackTrace.current; + + expect( + StreamNotification.data(value1).hashCode, + StreamNotification.data(value1).hashCode, + ); + expect( + StreamNotification.data(value1).hashCode, + StreamNotification.data(value1).hashCode, + ); + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.data(value2).hashCode), + ); + + expect( + StreamNotification.done().hashCode, + StreamNotification.done().hashCode, + ); + expect( + StreamNotification.done().hashCode, + StreamNotification.done().hashCode, + ); + + expect( + StreamNotification.error(value1, st1).hashCode, + StreamNotification.error(value1, st1).hashCode, + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value2, st1).hashCode), + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value1, st2).hashCode), + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value2, st2).hashCode), + ); + + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.done().hashCode), + ); + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.error(value1, st1).hashCode), + ); + expect( + StreamNotification.done().hashCode, + isNot(StreamNotification.error(value1, st1).hashCode), + ); + }); + + test('==', () { + final value1 = 1; + final value2 = 2; + + final st1 = StackTrace.current; + final st2 = StackTrace.current; + + expect( + StreamNotification.data(value1), + StreamNotification.data(value1), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.data(value1)), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.data(value2)), + ); + + expect( + StreamNotification.done(), + StreamNotification.done(), + ); + expect( + const StreamNotification.done(), + StreamNotification.done(), + ); + expect( + StreamNotification.done(), + StreamNotification.done(), + ); + + expect( + StreamNotification.error(value1, st1), + StreamNotification.error(value1, st1), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value2, st1)), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value1, st2)), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value2, st2)), + ); + + expect( + StreamNotification.data(value1), + isNot(StreamNotification.done()), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.error(value1, st1)), + ); + expect( + StreamNotification.done(), + isNot(StreamNotification.error(value1, st1)), + ); + }); + + test('toString', () { + expect( + StreamNotification.data(1).toString(), + 'DataNotification{value: 1}', + ); + + expect( + StreamNotification.done().toString(), + 'DoneNotification{}', + ); + + expect( + StreamNotification.error(2, StackTrace.empty).toString(), + 'ErrorNotification{error: 2, stackTrace: }', + ); + }); + + test('requireData', () { + expect( + StreamNotification.data(1).requireDataValue, + 1, + ); + + expect( + () => StreamNotification.done().requireDataValue, + throwsA(isA()), + ); + + expect( + () => + StreamNotification.error(2, StackTrace.empty).requireDataValue, + throwsA(isA()), + ); + }); + + test('errorAndStackTraceOrNull', () { + expect( + StreamNotification.data(1).errorAndStackTraceOrNull, + isNull, + ); + + expect( + StreamNotification.done().errorAndStackTraceOrNull, + isNull, + ); + + expect( + StreamNotification.error(2, StackTrace.empty) + .errorAndStackTraceOrNull, + ErrorAndStackTrace(2, StackTrace.empty), + ); + }); + + test('isOnData', () { + expect( + StreamNotification.data(1).isData, + isTrue, + ); + + expect( + StreamNotification.done().isData, + isFalse, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isData, + isFalse, + ); + }); + + test('isOnDone', () { + expect( + StreamNotification.data(1).isDone, + isFalse, + ); + + expect( + StreamNotification.done().isDone, + isTrue, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isDone, + isFalse, + ); + }); + + test('isOnError', () { + expect( + StreamNotification.data(1).isError, + isFalse, + ); + + expect( + StreamNotification.done().isError, + isFalse, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isError, + isTrue, + ); + }); + }); +}