diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b606cd4..ecaca80 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ updates: - package-ecosystem: npm directory: "/" schedule: - interval: weekly + interval: monthly target-branch: "develop" open-pull-requests-limit: 5 groups: @@ -19,7 +19,23 @@ updates: - package-ecosystem: gradle directory: "/" schedule: - interval: weekly + interval: monthly + target-branch: "develop" + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: + - minor + - patch + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: pub + directory: "/packages/core/flutter" + schedule: + interval: monthly target-branch: "develop" open-pull-requests-limit: 5 groups: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d17a3de --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,31 @@ +# Code of Conduct + +## Our Pledge + +We as contributors and maintainers pledge to make participation in KompKit a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +**Positive behavior includes:** +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +**Unacceptable behavior includes:** +- The use of sexualized language or imagery +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening a [GitHub issue](https://github.com/Kompkit/KompKit/issues) or contacting the maintainers directly via GitHub. + +All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. diff --git a/README.md b/README.md index 7a85a41..45e84c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KompKit -[![Version](https://img.shields.io/badge/version-0.1.0--alpha-orange.svg)](https://github.com/Kompkit/KompKit/releases) +[![Version](https://img.shields.io/badge/version-0.3.0--alpha.0-orange.svg)](https://github.com/Kompkit/KompKit/releases) [![Web CI](https://github.com/Kompkit/KompKit/actions/workflows/web.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/web.yml) [![Kotlin CI](https://github.com/Kompkit/KompKit/actions/workflows/android.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/android.yml) [![Flutter CI](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml/badge.svg?branch=develop)](https://github.com/Kompkit/KompKit/actions/workflows/flutter.yml) @@ -9,9 +9,37 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-0095D5?logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Dart](https://img.shields.io/badge/Dart-0175C2?logo=dart&logoColor=white)](https://dart.dev/) -> **⚠️ Alpha Release**: This is an early alpha version. APIs may change before stable release. +> **⚠️ Alpha Release**: This is an early alpha version. APIs may change before stable release. See [Stability Policy](#stability-policy) below. -A lightweight cross-platform utility kit providing essential functions for Web (TypeScript), Android (Kotlin), and Flutter (Dart) development. Built as a monorepo with identical APIs across platforms. +A lightweight cross-platform utility kit providing essential functions for Web (TypeScript), Android (Kotlin), and Flutter (Dart) development. Built as a monorepo with conceptual API parity across platforms. + +## Why KompKit? + +Most utility libraries are platform-specific. When you build a product across Web, Android, and Flutter, you end up with three different utility ecosystems, three different mental models, and three different sets of edge-case behaviors. + +KompKit solves this by providing the same utilities — with the same names, the same defaults, and the same behavioral semantics — across all three platforms. You learn the API once. You use it everywhere. + +**What it is:** + +- A small, focused set of production-safe utility functions +- Conceptually identical across TypeScript, Kotlin, and Dart +- Idiomatic per platform — no forced unnatural APIs +- Minimal dependencies, no runtime bloat + +**What it is not:** + +- A replacement for lodash, Kotlin stdlib, or Dart's core libraries +- A UI component library +- A framework or abstraction layer + +## Target Audience + +KompKit is for teams and developers who: + +- Build products across **multiple platforms simultaneously** (e.g., a web app + Android app + Flutter app) +- Want **consistent utility behavior** without maintaining separate implementations per platform +- Value **minimal dependencies** and **predictable APIs** +- Are comfortable with alpha software and want to shape the API before 1.0 ## Overview @@ -67,7 +95,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.2.0-alpha.0 + kompkit_core: ^0.3.0-alpha.0 ``` Then run: @@ -183,7 +211,7 @@ KompKit/ ## Version Information -- **Current Version**: `0.2.0-alpha` +- **Current Version**: `0.3.0-alpha` - **Minimum Requirements**: - Node.js 20+ (Web) - JDK 17+ (Android) @@ -192,6 +220,28 @@ KompKit/ - Kotlin 2.3+ - Dart 3.0+ +## Stability Policy + +KompKit is currently in **alpha**. This means: + +- **APIs may change** between alpha versions without a deprecation period. +- **Pin to exact versions** in production: `"kompkit-core": "0.3.0-alpha.0"` / `kompkit_core: 0.3.0-alpha.0`. +- **Breaking changes** will be documented in [CHANGELOG.md](./docs/CHANGELOG.md) with migration notes. +- Once `1.0.0` is released, the project will follow strict [Semantic Versioning](https://semver.org/): breaking changes only in major versions. + +## Platform Differences + +KompKit aims for **conceptual parity**, not syntactic identity. The following differences are intentional and documented: + +| Utility | Platform | Difference | Reason | +| ---------------- | -------- | -------------------------------------------------------------- | ----------------------------------------------------------- | +| `debounce` | Kotlin | Requires `CoroutineScope` parameter | Structured concurrency — no global timer API on JVM | +| `debounce` | Kotlin | Action is first parameter, scope is last | Enables idiomatic trailing lambda syntax | +| `formatCurrency` | Kotlin | Accepts `String` locale, converts to `Locale` internally | JVM `NumberFormat` requires `java.util.Locale` | +| `formatCurrency` | Dart | Accepts BCP 47 locale, normalizes hyphen→underscore internally | `intl` package uses underscore-separated locale identifiers | + +All platforms accept BCP 47 locale strings (e.g., `"en-US"`). All platforms throw on invalid `currency` or `locale` inputs. + ## Contributing We welcome contributions! Please see our [Contributing Guide](./docs/CONTRIBUTING.md) for details on: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..191945b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --------------- | ---------------------- | +| `0.3.0-alpha.0` | ✅ Current | +| `0.2.0-alpha.0` | ❌ No longer supported | +| `0.1.0-alpha` | ❌ No longer supported | + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +Please report security vulnerabilities by emailing the maintainers via a [GitHub Security Advisory](https://github.com/Kompkit/KompKit/security/advisories/new). + +Include: + +- A description of the vulnerability +- Steps to reproduce +- Potential impact +- Any suggested fix (optional) + +We will acknowledge receipt within 48 hours and aim to provide a fix or mitigation within 14 days for confirmed vulnerabilities. + +## Scope + +KompKit Core is a utility library with no network access, no file system access, and no external runtime dependencies (Web/Android). The attack surface is limited to: + +- Input validation logic (`isEmail`) — regex denial-of-service (ReDoS) is in scope +- Dependency vulnerabilities in `intl` (Dart) or `kotlinx-coroutines` (Kotlin) + +## Out of Scope + +- Vulnerabilities in development-only dependencies (test runners, build tools) +- Issues in generated documentation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee7e678..56c6d4f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,66 +46,127 @@ All modules implement identical functionality: - **isEmail**: Email validation using regex patterns - **formatCurrency**: Localized currency formatting +## API Parity Contract + +This section defines the formal contract for cross-platform API consistency in KompKit. + +### What is Guaranteed + +- **Function names** are identical across all platforms (`debounce`, `isEmail`, `formatCurrency`). +- **Behavioral semantics** are identical: given the same inputs, all platforms produce the same observable output. +- **Default values** are identical: `wait = 250ms`, `currency = "EUR"`, `locale = "en-US"`. +- **Error handling philosophy** is consistent: invalid inputs that cannot produce a meaningful result throw/throw-equivalent errors. Silent fallbacks are not permitted. +- **Cancel capability**: `debounce` returns an object with a `cancel()` method on all platforms, allowing callers to discard pending executions (required for safe use in component lifecycles). + +### What May Differ + +- **Parameter style**: Named parameters (Dart), trailing lambdas (Kotlin), and positional parameters (TypeScript) are idiomatic per language and are not forced to match syntactically. +- **Coroutine scope (Kotlin)**: `debounce` requires a `CoroutineScope` because Kotlin's async model mandates structured concurrency. This is a platform constraint, not an API inconsistency. The scope is the last parameter to allow trailing lambda syntax. +- **Type system expression**: Dart uses `void Function(T)` return types, Kotlin uses `(T) -> Unit`, TypeScript uses a typed wrapper object. All three express the same concept. +- **Locale string format**: All platforms accept BCP 47 locale strings (e.g., `"en-US"`). Kotlin converts internally to `java.util.Locale` as required by the JVM. + +### Conceptual Mental Model + +Every utility follows the same mental model regardless of platform: + +``` +debounce(action, options) → Debounced (with .cancel()) +isEmail(value) → Boolean +formatCurrency(amount, options) → String +``` + +A developer familiar with the TypeScript API should be able to use the Kotlin or Dart API with only idiomatic adjustments — not conceptual re-learning. + +### Platform Divergence Documentation + +Any unavoidable divergence between platforms must be: + +1. Documented in this section. +2. Explained with the platform constraint that necessitates it. +3. Kept minimal — divergence is a cost, not a feature. + +**Current documented divergences:** + +| Function | Divergence | Reason | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `debounce` | Kotlin requires `CoroutineScope` parameter | Structured concurrency — no global timer API | +| `debounce` | Kotlin uses trailing lambda for `action` | Idiomatic Kotlin; improves call-site readability | +| `formatCurrency` | Kotlin accepts `String` locale, converts to `Locale` internally | JVM `NumberFormat` API requires `java.util.Locale` | +| `formatCurrency` | TypeScript (V8) and Kotlin (JVM) fall back on unknown locales; Dart throws | `Intl.NumberFormat` (V8) and `Locale.forLanguageTag` (JVM) are lenient; `intl` (Dart) calls `verifiedLocale()` which throws | +| `formatCurrency` | Dart validates currency format via regex (3 uppercase letters); Kotlin via `Currency.getInstance`; TypeScript via `Intl.NumberFormat` | Platform APIs differ in how they enforce ISO 4217 — all throw on invalid codes | + +--- + ## Implementation Strategy ### API Design -We maintain strict API consistency across platforms: +We maintain conceptual API parity across platforms: **TypeScript:** ```typescript -export function debounce any>( +interface Debounced void> { + (...args: Parameters): void; + cancel(): void; +} + +export function debounce void>( fn: T, - wait: number = 250, -): T; + wait?: number, // default: 250 +): Debounced; export function isEmail(value: string): boolean; export function formatCurrency( amount: number, - currency: string = "EUR", - locale: string = "es-ES", + currency?: string, // default: "EUR" + locale?: string, // default: "en-US" ): string; ``` **Kotlin:** ```kotlin +class Debounced(private val action: (T) -> Unit) { + operator fun invoke(value: T): Unit + fun cancel(): Unit +} + fun debounce( + action: (T) -> Unit, waitMs: Long = 250L, - scope: CoroutineScope, - dest: (T) -> Unit -): (T) -> Unit + scope: CoroutineScope, // platform constraint: structured concurrency +): Debounced fun isEmail(value: String): Boolean fun formatCurrency( amount: Double, currency: String = "EUR", - locale: Locale = Locale("es", "ES") + locale: String = "en-US", // converted internally to java.util.Locale ): String ``` **Dart:** ```dart -Function debounce( - Function fn, - [Duration wait = const Duration(milliseconds: 250)] -); +class Debounced { + void call(T arg); + void cancel(); +} -VoidCallback debounceVoid( - VoidCallback fn, - [Duration wait = const Duration(milliseconds: 250)] -); +Debounced debounce( + void Function(T) action, [ + Duration wait = const Duration(milliseconds: 250), +]); bool isEmail(String value); String formatCurrency( num amount, { String currency = "EUR", - String locale = "es_ES", + String locale = "en-US", }); ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1a9887c..c8ea1f1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.2.0-alpha] - 2026-02-09 +## [0.3.0-alpha] - 2026-02-09 ### Changed @@ -111,7 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Known Limitations - **Alpha release**: APIs may change before stable 1.0 release -- **Local development only**: Packages not yet published to registries +- **Android not yet published**: Android/Kotlin package is not yet published to Maven; use local project reference - **Limited utility set**: Only 3 core functions (debounce, isEmail, formatCurrency) ### Migration Notes diff --git a/docs/contributing.md b/docs/CONTRIBUTING.md similarity index 97% rename from docs/contributing.md rename to docs/CONTRIBUTING.md index 2209916..5290a56 100644 --- a/docs/contributing.md +++ b/docs/CONTRIBUTING.md @@ -88,7 +88,7 @@ test(web): add edge cases for email validation npm run build # Build web package only -npm run docs:web +npm run build:web # Build Kotlin package only cd packages/core/android && ./gradlew assemble @@ -169,7 +169,7 @@ When adding a new utility function: 4. **Update exports**: - Add to `packages/core/web/src/index.ts` - - Add to `packages/core/flutter/src/kompkit_core.dart` + - Add to `packages/core/flutter/lib/kompkit_core.dart` - Kotlin exports are automatic via package structure 5. **Document with examples**: @@ -267,6 +267,8 @@ Releases are managed by maintainers: - 💬 **Discussions**: [GitHub Discussions](https://github.com/Kompkit/KompKit/discussions) - 📧 **Maintainers**: Open an issue for direct contact +> See also: [CONTRIBUTING.md](../docs/CONTRIBUTING.md) is auto-linked by GitHub in the PR interface. + ## Code of Conduct This project follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/). Please be respectful and inclusive in all interactions. diff --git a/docs/android.md b/docs/android.md index 0f5d953..a671568 100644 --- a/docs/android.md +++ b/docs/android.md @@ -2,7 +2,7 @@ KompKit Core provides small utilities for Android applications written in Kotlin. -Status: `V0.2.0-alpha`. +Status: `V0.3.0-alpha`. ## Installation diff --git a/docs/flutter.md b/docs/flutter.md index e871ebf..427715e 100644 --- a/docs/flutter.md +++ b/docs/flutter.md @@ -2,7 +2,7 @@ This guide covers using KompKit Core utilities in Flutter and Dart applications. -Status: `V0.2.0-alpha`. +Status: `V0.3.0-alpha`. ## Installation @@ -12,7 +12,7 @@ Add KompKit Core to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.2.0-alpha.0 + kompkit_core: ^0.3.0-alpha.0 ``` Then run: @@ -29,7 +29,7 @@ For server-side Dart projects, add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.2.0-alpha.0 + kompkit_core: ^0.3.0-alpha.0 ``` Then run: diff --git a/docs/getting-started.md b/docs/getting-started.md index 2cf035b..26545ca 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ KompKit Core is a small cross-platform utility library for Web (TypeScript), Android (Kotlin), and Flutter (Dart). -Status: `V0.2.0-alpha`. +Status: `V0.3.0-alpha`. ## Install @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.2.0-alpha.0 + kompkit_core: ^0.3.0-alpha.0 ``` Then run: diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index d17af8d..0000000 --- a/docs/index.md +++ /dev/null @@ -1,23 +0,0 @@ -# KompKit Core - -Tiny, cross-platform utilities for **Web (TypeScript)**, **Android (Kotlin)**, and **Flutter (Dart)**. - -Status: `V0.2.0-alpha`. - -## Utilities - -- `debounce` — Debounce a function call by a configurable delay. -- `isEmail` — Validate if a string matches a basic email pattern. -- `formatCurrency` — Format numbers as localized currency strings. - -## Documentation - -- Getting started: [docs/getting-started.md](./getting-started.md) -- Web guide: [docs/web.md](./web.md) -- Android guide: [docs/android.md](./android.md) -- Flutter guide: [docs/flutter.md](./flutter.md) -- Recipes: [docs/recipes.md](./recipes.md) -- API Reference (Web): [docs/api/web](./api/web/) -- API Reference (Android): [docs/api/android](./api/android/) -- API Reference (Flutter): [docs/api/flutter](./api/flutter/) -- Roadmap: [docs/roadmap.md](./roadmap.md) diff --git a/docs/recipes.md b/docs/recipes.md index 75e0bef..6ca6da6 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -21,6 +21,7 @@ function SearchComponent() { }, 400); search(query); + return () => search.cancel(); // cleanup on unmount or query change }, [query]); return ( @@ -56,13 +57,13 @@ fun SearchScreen() { val scope = rememberCoroutineScope() val search = remember { - debounce(400L, scope) { q -> + debounce({ q -> scope.launch { if (q.isNotEmpty()) { results = fetchResults(q) } } - } + }, waitMs = 400L, scope = scope) } Column { @@ -101,7 +102,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State { final TextEditingController _controller = TextEditingController(); - late final Function(String) _debouncedSearch; + late final Debounced _debouncedSearch; List _results = []; @override @@ -116,6 +117,12 @@ class _SearchScreenState extends State { }, const Duration(milliseconds: 400)); } + @override + void dispose() { + _debouncedSearch.cancel(); + super.dispose(); + } + Future> _fetchResults(String query) async { // API call here await Future.delayed(Duration(milliseconds: 500)); // Simulate API delay @@ -169,7 +176,7 @@ function PriceDisplay({ amount }: { amount: number }) { "en-US": { currency: "USD", locale: "en-US" }, "es-ES": { currency: "EUR", locale: "es-ES" }, "ja-JP": { currency: "JPY", locale: "ja-JP" }, - }; + } as const; const config = localeConfig[locale]; const formatted = formatCurrency(amount, config.currency, config.locale); @@ -239,12 +246,12 @@ class PriceDisplay extends StatefulWidget { } class _PriceDisplayState extends State { - String _selectedLocale = 'en_US'; + String _selectedLocale = 'en-US'; final Map> _localeConfig = { - 'en_US': {'currency': 'USD', 'locale': 'en_US'}, - 'es_ES': {'currency': 'EUR', 'locale': 'es_ES'}, - 'ja_JP': {'currency': 'JPY', 'locale': 'ja_JP'}, + 'en-US': {'currency': 'USD', 'locale': 'en-US'}, + 'es-ES': {'currency': 'EUR', 'locale': 'es-ES'}, + 'ja-JP': {'currency': 'JPY', 'locale': 'ja-JP'}, }; @override @@ -259,7 +266,7 @@ class _PriceDisplayState extends State { return Column( children: [ Text( - 'Price: $formatted', + 'Price: \$formatted', style: Theme.of(context).textTheme.headlineMedium, ), SizedBox(height: 16), diff --git a/docs/roadmap.md b/docs/roadmap.md index 502fe3e..e2e5d62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,48 +1,49 @@ # Roadmap -KompKit Core is in early development. This roadmap outlines planned features and improvements. +## Current State — `0.3.0-alpha.0` -## Current phase: Stability and parity +KompKit Core is in early alpha. The current release includes: -The focus is on: +- **3 utilities**: `debounce`, `isEmail`, `formatCurrency` +- **3 platforms**: TypeScript (npm), Dart/Flutter (pub.dev), Kotlin/JVM (local only) +- **Conceptual API parity** across all platforms with documented divergences +- **Full CI/CD** with path-based workflow optimization +- **Cancel support** on `debounce` across all platforms -- Ensuring API consistency between web, android, and flutter implementations -- Expanding test coverage -- Fixing bugs and edge cases -- Improving documentation +## Next: `0.3.0-alpha` -## Upcoming utilities +Focus: utility expansion and Android publishing. -- `throttle` - Throttle function calls to a maximum rate -- `clamp` - Clamp a number between min and max values -- `deepEqual` - Deep equality comparison for objects -- `retry` - Retry failed async operations with backoff -- `sleep` - Promise-based delay utility +- `throttle` — Rate-limit function calls (natural companion to `debounce`) +- `clamp` — Clamp a number between min and max +- `sleep` — Promise/Future/suspend-based delay utility +- Publish Android/Kotlin package to Maven Central +- Add `exports` subpath entries for individual utilities (tree-shaking improvement) -## Design tokens (future) +## After: `0.4.0-alpha` -- Color palettes -- Typography scales -- Spacing systems -- Cross-platform theme definitions +Focus: async utilities and deeper platform integration. -## Documentation site +- `retry` — Retry failed async operations with configurable backoff +- `deepEqual` — Deep equality comparison for plain objects/maps +- React hooks companion package (`kompkit-react`) — thin wrappers over core utilities -- Dedicated docs site with interactive examples -- API reference auto-generated from source -- Live playground for testing utilities +## Toward `1.0.0` -## Release plan +Before a stable 1.0 release, the following must be true: -- `0.1.0` - First beta release with stable API for current utilities -- `0.2.0` - Additional utilities (throttle, clamp, deepEqual) -- `1.0.0` - Stable release with full test coverage and documentation +- All three platform packages published to their respective registries +- API contract frozen with a documented migration guide for any breaking changes +- Behavioral test coverage for all edge cases across all platforms +- Dedicated documentation site with live examples +- No known behavioral divergences between platforms -## Long-term considerations +## Long-term (post-1.0) -- Optional React hooks package (`@kompkit/react`) -- Optional Vue composables package (`@kompkit/vue`) -- UI component library (post-1.0) -- Additional platform support (iOS native) +- Vue composables package (`kompkit-vue`) +- iOS/Swift implementation following the same API contract +- Design token utilities (color, spacing, typography) — separate package -Timelines are subject to change based on community feedback and contributions. +--- + +Timelines are not fixed. Priorities are driven by community feedback and real-world usage. diff --git a/docs/web.md b/docs/web.md index b815d1f..0465445 100644 --- a/docs/web.md +++ b/docs/web.md @@ -2,7 +2,7 @@ KompKit Core provides small, framework-agnostic utilities for web applications written in TypeScript. -Status: `V0.2.0-alpha`. +Status: `V0.3.0-alpha`. ## Installation diff --git a/lerna.json b/lerna.json index 0bee1f7..baeeca5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "npmClient": "npm", "packages": ["packages/core/web"], "command": { diff --git a/package-lock.json b/package-lock.json index 3a26aae..3afade3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kompkit", - "version": "0.2.0-alpha", + "version": "0.3.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kompkit", - "version": "0.2.0-alpha", + "version": "0.3.0-alpha", "license": "MIT", "workspaces": [ "packages/core/web" @@ -8969,13 +8969,16 @@ }, "packages/core/web": { "name": "kompkit-core", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "license": "MIT", "devDependencies": { "tsup": "^8.5.0", "typedoc": "^0.28.16", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20" } } } diff --git a/package.json b/package.json index dec9a09..6c5bf5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kompkit", - "version": "0.2.0-alpha", + "version": "0.3.0-alpha", "description": "A lightweight cross-platform utility kit for Web (TypeScript), Android (Kotlin), and Flutter (Dart): debounce, isEmail, formatCurrency", "private": true, "workspaces": [ diff --git a/packages/core/android/src/main/kotlin/com/kompkit/core/Debounce.kt b/packages/core/android/src/main/kotlin/com/kompkit/core/Debounce.kt index 0291a64..ad34bd6 100644 --- a/packages/core/android/src/main/kotlin/com/kompkit/core/Debounce.kt +++ b/packages/core/android/src/main/kotlin/com/kompkit/core/Debounce.kt @@ -5,37 +5,63 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +/** + * A debounced function wrapper with a [cancel] method. + * + * Invoke it like a regular function; call [cancel] to discard any pending execution + * without invoking the action (e.g., on ViewModel `onCleared` or composable disposal). + * + * @param T The type of the argument accepted by the debounced action. + */ +class Debounced(private val invoke: (T) -> Unit) { + /** Invokes the debounced function with [value]. */ + operator fun invoke(value: T) = invoke.invoke(value) + + /** Cancels any pending invocation without executing it. */ + fun cancel() = _cancel() + + internal var _cancel: () -> Unit = {} +} + /** * Debounces consecutive calls and emits only the last one after [waitMs] milliseconds. * Subsequent calls within the wait period cancel the previous scheduled execution. * - * @param T The type of parameter accepted by the debounced function. - * @param waitMs Milliseconds to wait before invoking the destination callback. Defaults to 250ms. + * The [action] is the conceptual first argument — what to debounce. + * [waitMs] configures the delay (default 250ms). + * [scope] is a platform constraint required for structured concurrency. + * + * Returns a [Debounced] wrapper that can be invoked like a function and supports [Debounced.cancel]. + * + * @param T The type of parameter accepted by the debounced action. + * @param action The callback to invoke after the debounce period elapses. + * @param waitMs Milliseconds to wait before invoking [action]. Defaults to 250ms. * @param scope Coroutine scope used to schedule the delayed execution. - * @param dest Destination callback invoked after the debounce period elapses. - * @return A debounced function that accepts a parameter of type [T]. + * @return A [Debounced] wrapper. * * @sample * ```kotlin * val scope = CoroutineScope(Dispatchers.Main) - * val search = debounce(300L, scope) { query -> + * val search = debounce(scope = scope) { query -> * println("Searching: $query") * } - * search("hello") // Will execute after 300ms if no other calls are made + * search("hello") // Will execute after 250ms if no other calls are made + * search.cancel() // Discards the pending call (e.g., in onCleared) * ``` */ fun debounce( + action: (T) -> Unit, waitMs: Long = 250L, scope: CoroutineScope, - dest: (T) -> Unit, -): (T) -> Unit { +): Debounced { var job: Job? = null - return { param: T -> + val debounced = Debounced { param -> job?.cancel() - job = - scope.launch { - delay(waitMs) - dest(param) - } + job = scope.launch { + delay(waitMs) + action(param) + } } + debounced._cancel = { job?.cancel(); job = null } + return debounced } diff --git a/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt b/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt index cda93d1..164e30f 100644 --- a/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt +++ b/packages/core/android/src/main/kotlin/com/kompkit/core/Format.kt @@ -7,24 +7,38 @@ import java.util.Locale /** * Formats a number as a localized currency string. * + * Accepts a BCP 47 locale string (e.g., `"en-US"`, `"es-ES"`, `"ja-JP"`) and converts it internally + * to a [Locale] as required by the JVM — callers do not need to construct [Locale] objects + * directly. + * + * Throws [IllegalArgumentException] if [currency] is not a valid ISO 4217 code. Locale strings are + * parsed leniently by the JVM; an unrecognized locale falls back to the root locale rather than + * throwing. + * * @param amount The numeric amount to format. - * @param currency The currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". - * @param locale The locale for formatting. Defaults to Spanish (Spain). + * @param currency ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". + * @param locale BCP 47 locale string (e.g., "en-US", "es-ES"). Defaults to "en-US". * @return A formatted currency string. + * @throws IllegalArgumentException if [currency] is not a valid ISO 4217 code. * * @sample * ```kotlin - * formatCurrency(1234.56) // "1.234,56 €" (es-ES default) - * formatCurrency(1234.56, "USD", Locale.US) // "$1,234.56" - * formatCurrency(1000.0, "JPY", Locale.JAPAN) // "¥1,000" + * formatCurrency(1234.56) // "$1,234.56" (en-US default) + * formatCurrency(1234.56, "EUR", "es-ES") // "1.234,56 €" + * formatCurrency(1000.0, "JPY", "ja-JP") // "¥1,000" * ``` */ fun formatCurrency( - amount: Double, - currency: String = "EUR", - locale: Locale = Locale("es", "ES"), + amount: Double, + currency: String = "EUR", + locale: String = "en-US", ): String { - val nf = NumberFormat.getCurrencyInstance(locale) - nf.currency = Currency.getInstance(currency) - return nf.format(amount) + val jvmLocale = Locale.forLanguageTag(locale) + val currencyInstance = + runCatching { Currency.getInstance(currency) }.getOrElse { + throw IllegalArgumentException("Invalid currency code: '$currency'") + } + val nf = NumberFormat.getCurrencyInstance(jvmLocale) + nf.currency = currencyInstance + return nf.format(amount) } diff --git a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt index ad187e3..e9f9b5e 100644 --- a/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt +++ b/packages/core/android/src/test/kotlin/com/kompkit/core/CoreTests.kt @@ -1,33 +1,150 @@ package com.kompkit.core +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue -class CoreTests { +class IsEmailTests { + @Test + fun validEmails() { + assertTrue(isEmail("user@example.com")) + assertTrue(isEmail("test.email+tag@domain.org")) + assertTrue(isEmail("a@b.co")) + } + @Test - fun testIsEmail() { - assertTrue(isEmail("test@example.com")) - assertFalse(isEmail("invalid")) + fun invalidEmails() { + assertFalse(isEmail("invalid@")) + assertFalse(isEmail("@invalid.com")) + assertFalse(isEmail("nodomain")) + assertFalse(isEmail("missing@tld")) + assertFalse(isEmail("two@@domain.com")) } @Test - fun testFormatCurrency() { - val formatted = formatCurrency(1234.56) - assertTrue(formatted.contains("€")) + fun emptyString() { + assertFalse(isEmail("")) } @Test - fun testDebounce() = - runBlocking { - var called = false - val debounced = debounce(200, this) { called = true } - debounced("a") - delay(100) - assertFalse(called) - delay(200) - assertTrue(called) - } + fun trimsWhitespace() { + assertTrue(isEmail(" user@example.com ")) + assertFalse(isEmail(" invalid@ ")) + } +} + +class FormatCurrencyTests { + @Test + fun defaultLocaleIsEnUs() { + val result = formatCurrency(1234.56) + assertTrue(result.contains("1,234.56"), "Expected en-US formatted number, got: $result") + assertTrue(result.contains("€"), "Expected EUR symbol, got: $result") + } + + @Test + fun formatsUsdExplicitly() { + val result = formatCurrency(1234.56, "USD", "en-US") + assertTrue(result.contains("1,234.56"), "Expected en-US formatted number, got: $result") + assertTrue(result.contains("$"), "Expected USD symbol, got: $result") + } + + @Test + fun formatsEurWithEsEsLocale() { + val result = formatCurrency(1234.56, "EUR", "es-ES") + assertTrue(result.contains("1.234,56"), "Expected es-ES formatted number, got: $result") + assertTrue(result.contains("€"), "Expected EUR symbol, got: $result") + } + + @Test + fun formatsJpyWithJaJpLocale() { + val result = formatCurrency(1000.0, "JPY", "ja-JP") + assertTrue(result.contains("1,000"), "Expected formatted number, got: $result") + } + + @Test + fun handlesZero() { + val result = formatCurrency(0.0, "USD", "en-US") + assertTrue(result.contains("0"), "Expected zero, got: $result") + } + + @Test + fun handlesNegativeAmounts() { + val result = formatCurrency(-50.25, "USD", "en-US") + assertTrue(result.contains("50.25"), "Expected amount, got: $result") + } + + @Test + fun throwsForInvalidCurrency() { + assertFailsWith { formatCurrency(100.0, "INVALID", "en-US") } + } + + @Test + fun unrecognizedLocaleFallsBackGracefully() { + // JVM Locale.forLanguageTag is lenient — unknown locales fall back to root locale. + // This matches TypeScript (V8) behavior. Only currency codes are strictly validated. + val result = formatCurrency(100.0, "USD", "zz-ZZ-unknown") + assertTrue(result.isNotEmpty(), "Expected non-empty result for unknown locale, got: $result") + } +} + +class DebounceTests { + @Test + fun doesNotCallImmediately() = runBlocking { + var called = false + val debounced = debounce({ called = true }, 200L, this) + debounced("a") + assertFalse(called) + } + + @Test + fun callsAfterWaitPeriod() = runBlocking { + var called = false + val debounced = debounce({ called = true }, 100L, this) + debounced("a") + delay(50) + assertFalse(called) + delay(100) + assertTrue(called) + } + + @Test + fun onlyCallsOnceForRapidCalls() = runBlocking { + var callCount = 0 + var lastValue = "" + val debounced = + debounce( + { v -> + callCount++ + lastValue = v + }, + 100L, + this + ) + debounced("a") + debounced("b") + debounced("c") + delay(200) + assertEquals(1, callCount) + assertEquals("c", lastValue) + } + + @Test + fun cancelPreventsExecution() = runBlocking { + var called = false + val debounced = debounce({ called = true }, 100L, this) + debounced("a") + debounced.cancel() + delay(200) + assertFalse(called) + } + + @Test + fun cancelIsSafeWhenNoPendingCall() = runBlocking { + val debounced = debounce({}, 100L, this) + debounced.cancel() // should not throw + } } diff --git a/packages/core/flutter/CHANGELOG.md b/packages/core/flutter/CHANGELOG.md index 3173b2c..cc442f5 100644 --- a/packages/core/flutter/CHANGELOG.md +++ b/packages/core/flutter/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.0-alpha.0 +## 0.3.0-alpha.0 - Initial alpha release of `kompkit_core` for Flutter/Dart - **debounce** — Delay function execution with cancellation (`debounce`, `debounceVoid`) diff --git a/packages/core/flutter/README.md b/packages/core/flutter/README.md index 9b9462a..291b609 100644 --- a/packages/core/flutter/README.md +++ b/packages/core/flutter/README.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - kompkit_core: ^0.2.0-alpha.0 + kompkit_core: ^0.3.0-alpha.0 ``` > Published on [pub.dev/packages/kompkit_core](https://pub.dev/packages/kompkit_core) diff --git a/packages/core/flutter/lib/src/debounce.dart b/packages/core/flutter/lib/src/debounce.dart index 4eb5903..5ef518b 100644 --- a/packages/core/flutter/lib/src/debounce.dart +++ b/packages/core/flutter/lib/src/debounce.dart @@ -1,65 +1,59 @@ import 'dart:async'; +/// A debounced function wrapper with a [cancel] method. +/// +/// Invoke it like the original function; call [cancel] to discard any pending +/// execution without invoking the action (e.g., in [State.dispose]). +/// +/// ```dart +/// final search = debounce((query) => print('Searching: $query')); +/// search('hello'); // schedules execution after 250ms +/// search.cancel(); // discards the pending call +/// ``` +class Debounced { + final void Function(T) _action; + final Duration _wait; + Timer? _timer; + + Debounced._(this._action, this._wait); + + /// Schedules [action] to be called with [arg] after the wait period. + /// Resets the timer if called again before the wait elapses. + void call(T arg) { + _timer?.cancel(); + _timer = Timer(_wait, () => _action(arg)); + } + + /// Cancels any pending invocation without executing it. + void cancel() { + _timer?.cancel(); + _timer = null; + } +} + /// Debounces a function call by delaying its execution until after a specified wait period. -/// -/// Subsequent calls within the wait period reset the timer, ensuring that the function -/// is only called once after the specified delay has elapsed without any new calls. -/// -/// This is particularly useful for scenarios like search input fields, where you want -/// to wait for the user to stop typing before making an API call. -/// +/// Subsequent calls within the wait period reset the timer. +/// +/// Returns a [Debounced] wrapper that can be called like the original function +/// and supports [Debounced.cancel] for cleanup. +/// /// **Parameters:** -/// - [fn] - The function to debounce +/// - [action] - The function to debounce /// - [wait] - Duration to wait before invoking the function (defaults to 250ms) -/// -/// **Returns:** A debounced version of the function that accepts a parameter of type [T] -/// +/// /// **Example:** /// ```dart /// final search = debounce((String query) { /// print('Searching: $query'); /// }, const Duration(milliseconds: 300)); -/// +/// /// search('hello'); // Will execute after 300ms if no other calls are made /// search('world'); // Previous call is cancelled, this will execute after 300ms +/// search.cancel(); // Discards the pending call (e.g., in dispose()) /// ``` -Function debounce(Function fn, [Duration wait = const Duration(milliseconds: 250)]) { - Timer? timer; - - return (T arg) { - timer?.cancel(); - timer = Timer(wait, () => fn(arg)); - }; +Debounced debounce( + void Function(T) action, [ + Duration wait = const Duration(milliseconds: 250), +]) { + return Debounced._(action, wait); } - -/// Debounces a void function (function with no parameters). -/// -/// Similar to [debounce], but specifically designed for functions that don't take parameters. -/// Useful for actions like saving data, refreshing UI, or other side effects. -/// -/// **Parameters:** -/// - [fn] - The void function to debounce -/// - [wait] - Duration to wait before invoking the function (defaults to 250ms) -/// -/// **Returns:** A debounced version of the void function -/// -/// **Example:** -/// ```dart -/// final saveData = debounceVoid(() { -/// print('Saving data...'); -/// }, const Duration(milliseconds: 500)); -/// -/// saveData(); // Will execute after 500ms if no other calls are made -/// saveData(); // Previous call is cancelled, this will execute after 500ms -/// ``` -VoidCallback debounceVoid(VoidCallback fn, [Duration wait = const Duration(milliseconds: 250)]) { - Timer? timer; - - return () { - timer?.cancel(); - timer = Timer(wait, fn); - }; -} - -/// Type definition for void callback functions -typedef VoidCallback = void Function(); diff --git a/packages/core/flutter/lib/src/format.dart b/packages/core/flutter/lib/src/format.dart index 7bcb8fa..5586f5f 100644 --- a/packages/core/flutter/lib/src/format.dart +++ b/packages/core/flutter/lib/src/format.dart @@ -1,61 +1,52 @@ import 'package:intl/intl.dart'; -/// Formats a number as a localized currency string. -/// -/// This function uses the Dart `intl` package to format numbers according to -/// the specified currency and locale conventions. It handles decimal places, -/// thousands separators, and currency symbols based on the locale. -/// -/// The function automatically determines the appropriate currency symbol and -/// formatting rules for the given locale. If an unsupported locale/currency -/// combination is provided, it falls back gracefully. -/// +/// Formats a number as a localized currency string using BCP 47 locale identifiers. +/// +/// Accepts BCP 47 locale strings (e.g., `"en-US"`, `"es-ES"`, `"ja-JP"`). +/// The `intl` package uses underscore-separated locale identifiers internally; +/// hyphens are converted automatically. +/// +/// Throws [ArgumentError] if [currency] is not a valid ISO 4217 code or +/// [locale] is not a recognized locale — consistent with TypeScript and Kotlin behavior. +/// /// **Parameters:** /// - [amount] - The numeric amount to format (supports both int and double) -/// - [currency] - The ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR" -/// - [locale] - The locale identifier (e.g., "en_US", "es_ES", "ja_JP"). Defaults to "es_ES" -/// +/// - [currency] - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR" +/// - [locale] - BCP 47 locale string (e.g., "en-US", "es-ES", "ja-JP"). Defaults to "en-US" +/// /// **Returns:** A formatted currency string according to the specified locale -/// +/// @throws [ArgumentError] if [currency] or [locale] is invalid. +/// /// **Example:** /// ```dart -/// // Default (EUR, Spanish locale) -/// formatCurrency(1234.56); // "1.234,56 €" -/// -/// // US Dollar -/// formatCurrency(1234.56, currency: "USD", locale: "en_US"); // "$1,234.56" -/// -/// // Japanese Yen (no decimal places) -/// formatCurrency(1000, currency: "JPY", locale: "ja_JP"); // "¥1,000" -/// -/// // British Pound -/// formatCurrency(999.99, currency: "GBP", locale: "en_GB"); // "£999.99" -/// -/// // Negative amounts -/// formatCurrency(-50.25, currency: "USD", locale: "en_US"); // "-$50.25" +/// formatCurrency(1234.56); // "$1,234.56" (en-US default) +/// formatCurrency(1234.56, currency: "EUR", locale: "es-ES"); // "1.234,56 €" +/// formatCurrency(1000, currency: "JPY", locale: "ja-JP"); // "¥1,000" +/// formatCurrency(999.99, currency: "GBP", locale: "en-GB"); // "£999.99" +/// formatCurrency(-50.25, currency: "USD", locale: "en-US"); // "-$50.25" /// ``` +/// ISO 4217 currency codes are exactly 3 uppercase ASCII letters. +final _currencyCodeRe = RegExp(r'^[A-Z]{3}$'); + String formatCurrency( num amount, { String currency = "EUR", - String locale = "es_ES", + String locale = "en-US", }) { + if (!_currencyCodeRe.hasMatch(currency)) { + throw ArgumentError('Invalid currency code: "$currency". Expected a 3-letter ISO 4217 code.'); + } + // Normalize BCP 47 hyphen separator to the underscore format expected by intl. + final normalizedLocale = locale.replaceAll('-', '_'); try { final formatter = NumberFormat.currency( - locale: locale, + locale: normalizedLocale, name: currency, ); return formatter.format(amount); + } on ArgumentError { + rethrow; } catch (e) { - // Fallback to a basic format if locale/currency combination is not supported - try { - final fallbackFormatter = NumberFormat.currency( - locale: 'en_US', - name: currency, - ); - return fallbackFormatter.format(amount); - } catch (e2) { - // Ultimate fallback: just return the number with currency code - return '$currency${amount.toStringAsFixed(2)}'; - } + throw ArgumentError('Invalid locale "$locale": $e'); } } diff --git a/packages/core/flutter/lib/src/kompkit_core.dart b/packages/core/flutter/lib/src/kompkit_core.dart deleted file mode 100644 index c2709df..0000000 --- a/packages/core/flutter/lib/src/kompkit_core.dart +++ /dev/null @@ -1,41 +0,0 @@ -/// KompKit Core - Cross-platform utilities for Flutter and Dart -/// -/// A lightweight utility library providing essential functions for Flutter -/// and Dart applications. Part of the KompKit ecosystem with identical APIs -/// across Web (TypeScript), Android (Kotlin), and Flutter (Dart) platforms. -/// -/// ## Features -/// -/// - **Cross-platform compatibility**: Identical APIs across all platforms -/// - **Type safety**: Full Dart null safety support -/// - **Zero dependencies**: Minimal external dependencies (only `intl` for formatting) -/// - **Comprehensive testing**: 100% test coverage -/// - **Rich documentation**: Detailed API docs with examples -/// -/// ## Available Utilities -/// -/// - [debounce] - Delay function execution with automatic cancellation -/// - [debounceVoid] - Debounce functions with no parameters -/// - [isEmail] - Email address validation using regex patterns -/// - [formatCurrency] - Localized currency formatting -/// -/// ## Quick Start -/// -/// ```dart -/// import 'package:kompkit_core/kompkit_core.dart'; -/// -/// // Debounce a search function -/// final search = debounce((query) => print('Searching: $query'), -/// const Duration(milliseconds: 300)); -/// -/// // Validate email addresses -/// print(isEmail('user@example.com')); // true -/// -/// // Format currency -/// print(formatCurrency(1234.56, currency: 'USD', locale: 'en_US')); // "$1,234.56" -/// ``` -library kompkit_core; - -export 'debounce.dart'; -export 'validate.dart'; -export 'format.dart'; diff --git a/packages/core/flutter/pubspec.yaml b/packages/core/flutter/pubspec.yaml index 359a5ca..03b90a6 100644 --- a/packages/core/flutter/pubspec.yaml +++ b/packages/core/flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: kompkit_core description: Cross-platform utility functions for Flutter and Dart applications. Part of the KompKit ecosystem. -version: 0.2.0-alpha.0 +version: 0.3.0-alpha.0 homepage: https://github.com/Kompkit/KompKit repository: https://github.com/Kompkit/KompKit issue_tracker: https://github.com/Kompkit/KompKit/issues diff --git a/packages/core/flutter/test/debounce_test.dart b/packages/core/flutter/test/debounce_test.dart index f8de686..88af7f0 100644 --- a/packages/core/flutter/test/debounce_test.dart +++ b/packages/core/flutter/test/debounce_test.dart @@ -3,77 +3,81 @@ import '../lib/src/debounce.dart'; void main() { group('debounce', () { - test('should delay function execution', () async { + test('does not call the action immediately', () async { var callCount = 0; - final debouncedFn = debounce((String value) { + final fn = debounce((String value) { callCount++; }, const Duration(milliseconds: 100)); - debouncedFn('test'); + fn('test'); expect(callCount, 0); + }); + + test('calls the action after the wait period', () async { + var callCount = 0; + final fn = debounce((String value) { + callCount++; + }, const Duration(milliseconds: 100)); + fn('test'); await Future.delayed(const Duration(milliseconds: 150)); expect(callCount, 1); }); - test('should cancel previous calls when called multiple times', () async { + test('only calls once for multiple rapid calls (debounce behavior)', () async { var callCount = 0; String? lastValue; - final debouncedFn = debounce((String value) { + final fn = debounce((String value) { callCount++; lastValue = value; }, const Duration(milliseconds: 100)); - debouncedFn('first'); - debouncedFn('second'); - debouncedFn('third'); + fn('first'); + fn('second'); + fn('third'); await Future.delayed(const Duration(milliseconds: 150)); expect(callCount, 1); expect(lastValue, 'third'); }); - test('should use default wait time of 250ms', () async { + test('uses 250ms default wait when not specified', () async { var callCount = 0; - final debouncedFn = debounce((String value) { - callCount++; - }); + final fn = debounce((String value) => callCount++); - debouncedFn('test'); - - // Should not execute before 250ms + fn('test'); await Future.delayed(const Duration(milliseconds: 200)); expect(callCount, 0); - // Should execute after 250ms await Future.delayed(const Duration(milliseconds: 100)); expect(callCount, 1); }); - }); - group('debounceVoid', () { - test('should delay void function execution', () async { + test('cancel() prevents the pending call from executing', () async { var callCount = 0; - final debouncedFn = debounceVoid(() { - callCount++; - }, const Duration(milliseconds: 100)); + final fn = debounce((String value) => callCount++, + const Duration(milliseconds: 100)); - debouncedFn(); - expect(callCount, 0); + fn('test'); + fn.cancel(); await Future.delayed(const Duration(milliseconds: 150)); - expect(callCount, 1); + expect(callCount, 0); }); - test('should cancel previous void calls when called multiple times', () async { + test('cancel() is safe to call when no call is pending', () { + final fn = debounce((String value) {}); + expect(() => fn.cancel(), returnsNormally); + }); + + test('void action works via debounce', () async { var callCount = 0; - final debouncedFn = debounceVoid(() { - callCount++; - }, const Duration(milliseconds: 100)); + final fn = debounce((_) => callCount++, + const Duration(milliseconds: 100)); - debouncedFn(); - debouncedFn(); - debouncedFn(); + fn(null); + fn(null); + fn(null); await Future.delayed(const Duration(milliseconds: 150)); expect(callCount, 1); diff --git a/packages/core/flutter/test/format_test.dart b/packages/core/flutter/test/format_test.dart index ca040d5..49db88b 100644 --- a/packages/core/flutter/test/format_test.dart +++ b/packages/core/flutter/test/format_test.dart @@ -3,55 +3,65 @@ import '../lib/src/format.dart'; void main() { group('formatCurrency', () { - test('should format currency with default EUR and es_ES locale', () { + test('default locale is en-US with EUR', () { final result = formatCurrency(1234.56); - expect(result, contains('1.234,56')); + expect(result, contains('1,234.56')); expect(result, contains('EUR')); }); - test('should format USD currency with en_US locale', () { - final result = formatCurrency(1234.56, currency: 'USD', locale: 'en_US'); + test('formats USD with en-US locale using BCP 47 string', () { + final result = formatCurrency(1234.56, currency: 'USD', locale: 'en-US'); expect(result, contains('1,234.56')); expect(result, contains('USD')); }); - test('should format JPY currency with ja_JP locale', () { - final result = formatCurrency(1000, currency: 'JPY', locale: 'ja_JP'); + test('formats EUR with es-ES locale using BCP 47 string', () { + final result = formatCurrency(1234.56, currency: 'EUR', locale: 'es-ES'); + expect(result, contains('1.234,56')); + expect(result, contains('EUR')); + }); + + test('formats JPY with ja-JP locale using BCP 47 string', () { + final result = formatCurrency(1000, currency: 'JPY', locale: 'ja-JP'); expect(result, contains('1,000')); expect(result, contains('JPY')); }); - test('should handle zero amounts', () { - final result = formatCurrency(0); + test('handles zero', () { + final result = formatCurrency(0, currency: 'USD', locale: 'en-US'); expect(result, contains('0')); }); - test('should handle negative amounts', () { - final result = formatCurrency(-100.50, currency: 'USD', locale: 'en_US'); + test('handles negative amounts', () { + final result = formatCurrency(-100.50, currency: 'USD', locale: 'en-US'); expect(result, contains('100.50')); expect(result.contains('-') || result.contains('('), true); }); - test('should handle large amounts', () { - final result = formatCurrency(1000000.99, currency: 'USD', locale: 'en_US'); + test('handles large amounts', () { + final result = formatCurrency(1000000.99, currency: 'USD', locale: 'en-US'); expect(result, contains('1,000,000.99')); }); - test('should handle decimal amounts', () { - final result = formatCurrency(0.99, currency: 'USD', locale: 'en_US'); + test('handles decimal amounts', () { + final result = formatCurrency(0.99, currency: 'USD', locale: 'en-US'); expect(result, contains('0.99')); }); - test('should handle integer amounts', () { - final result = formatCurrency(100, currency: 'USD', locale: 'en_US'); - expect(result, contains('100')); + test('throws ArgumentError for invalid currency code', () { + expect( + () => formatCurrency(100, currency: 'INVALID', locale: 'en-US'), + throwsArgumentError, + ); }); - test('should fallback gracefully for unsupported locale/currency combinations', () { - // This should not throw an error and should return some formatted string - final result = formatCurrency(100, currency: 'XYZ', locale: 'invalid_locale'); - expect(result, isA()); - expect(result.isNotEmpty, true); + test('throws ArgumentError for invalid locale', () { + // intl's NumberFormat throws on unrecognized locales via verifiedLocale(). + // This is caught and re-thrown as ArgumentError. + expect( + () => formatCurrency(100, currency: 'USD', locale: 'zz-ZZ-unknown'), + throwsArgumentError, + ); }); }); } diff --git a/packages/core/flutter/test/kompkit_core_test.dart b/packages/core/flutter/test/kompkit_core_test.dart index 466a84e..680724e 100644 --- a/packages/core/flutter/test/kompkit_core_test.dart +++ b/packages/core/flutter/test/kompkit_core_test.dart @@ -2,39 +2,54 @@ import 'package:flutter_test/flutter_test.dart'; import '../lib/kompkit_core.dart'; void main() { - group('KompKit Core Integration Tests', () { - test('should export all utilities', () { - // Test that all utilities are accessible through the main export + group('KompKit Core — exports', () { + test('all utilities are accessible through the main export', () { expect(debounce, isA()); - expect(debounceVoid, isA()); expect(isEmail, isA()); expect(formatCurrency, isA()); }); - test('should work together in a realistic scenario', () async { - // Simulate a search input with debounced email validation and currency formatting - var searchResults = []; - + test('debounce returns a Debounced instance', () { + final fn = debounce((String v) {}); + expect(fn, isA>()); + }); + }); + + group('KompKit Core — integration', () { + test('debounce + isEmail work together', () async { + final results = []; + final debouncedSearch = debounce((String query) { - if (isEmail(query)) { - searchResults.add('Found user: $query'); - } else { - searchResults.add('Invalid email: $query'); - } + results.add(isEmail(query) ? 'valid: $query' : 'invalid: $query'); }, const Duration(milliseconds: 100)); - // Test the integration - debouncedSearch('invalid-email'); + debouncedSearch('not-an-email'); debouncedSearch('user@example.com'); - + await Future.delayed(const Duration(milliseconds: 150)); - - expect(searchResults.length, 1); - expect(searchResults.first, 'Found user: user@example.com'); - - // Test currency formatting - final price = formatCurrency(99.99, currency: 'USD', locale: 'en_US'); + + expect(results.length, 1); + expect(results.first, 'valid: user@example.com'); + }); + + test('cancel() stops pending debounced call', () async { + final results = []; + + final debouncedSearch = debounce((String query) { + results.add(query); + }, const Duration(milliseconds: 100)); + + debouncedSearch('user@example.com'); + debouncedSearch.cancel(); + + await Future.delayed(const Duration(milliseconds: 150)); + expect(results, isEmpty); + }); + + test('formatCurrency uses en-US default', () { + final price = formatCurrency(99.99, currency: 'USD', locale: 'en-US'); expect(price, contains('99.99')); + expect(price, contains('USD')); }); }); } diff --git a/packages/core/flutter/test/validate_test.dart b/packages/core/flutter/test/validate_test.dart index 870d99c..360f416 100644 --- a/packages/core/flutter/test/validate_test.dart +++ b/packages/core/flutter/test/validate_test.dart @@ -3,21 +3,25 @@ import '../lib/src/validate.dart'; void main() { group('isEmail', () { - test('should return true for valid email addresses', () { + test('returns true for valid email addresses', () { expect(isEmail('user@example.com'), true); - expect(isEmail('test@example.com'), true); - expect(isEmail('test.email@domain.org'), true); + expect(isEmail('test.email+tag@domain.org'), true); + expect(isEmail('a@b.co'), true); }); - test('should return false for invalid email addresses', () { - expect(isEmail(''), false); - expect(isEmail('invalid'), false); + test('returns false for invalid email addresses', () { expect(isEmail('invalid@'), false); expect(isEmail('@invalid.com'), false); - expect(isEmail('invalid@com'), false); + expect(isEmail('nodomain'), false); + expect(isEmail('missing@tld'), false); + expect(isEmail('two@@domain.com'), false); + }); + + test('returns false for empty string', () { + expect(isEmail(''), false); }); - test('should handle whitespace by trimming', () { + test('trims whitespace before validating', () { expect(isEmail(' user@example.com '), true); expect(isEmail(' invalid@ '), false); }); diff --git a/packages/core/web/package.json b/packages/core/web/package.json index bd7015e..e48721d 100644 --- a/packages/core/web/package.json +++ b/packages/core/web/package.json @@ -1,11 +1,22 @@ { "name": "kompkit-core", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "description": "Cross-platform utility functions for web applications. Part of the KompKit ecosystem.", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "sideEffects": false, + "engines": { + "node": ">=20" + }, "license": "MIT", "repository": { "type": "git", diff --git a/packages/core/web/src/debounce.ts b/packages/core/web/src/debounce.ts index 1e48608..fcdd3d5 100644 --- a/packages/core/web/src/debounce.ts +++ b/packages/core/web/src/debounce.ts @@ -1,24 +1,49 @@ +/** + * A debounced function with a cancel method. + * Call it like the original function; call `.cancel()` to discard any pending execution. + */ +export interface Debounced void> { + (...args: Parameters): void; + /** Cancels any pending invocation without executing it. */ + cancel(): void; +} + /** * Debounces a function call by delaying its execution until after a specified wait period. * Subsequent calls within the wait period reset the timer. - * + * Returns a `Debounced` wrapper with a `cancel()` method for cleanup. + * * @param fn - The function to debounce. * @param wait - Milliseconds to wait before invoking the function. Defaults to 250ms. - * @returns A debounced version of the function. - * + * @returns A debounced wrapper with a `cancel()` method. + * * @example * ```ts * const search = debounce((query: string) => { * console.log('Searching:', query); * }, 300); - * + * * search('hello'); // Will execute after 300ms if no other calls are made + * search.cancel(); // Discards the pending call (e.g., on component unmount) * ``` */ -export function debounce any>(fn: T, wait = 250) { - let t: ReturnType | null = null; - return (...args: Parameters) => { - if (t) clearTimeout(t); - t = setTimeout(() => fn(...args), wait); +export function debounce void>( + fn: T, + wait = 250, +): Debounced { + let timer: ReturnType | null = null; + + const debounced = (...args: Parameters): void => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), wait); + }; + + debounced.cancel = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } }; + + return debounced as Debounced; } diff --git a/packages/core/web/src/format.ts b/packages/core/web/src/format.ts index cf5be7b..11b4c5c 100644 --- a/packages/core/web/src/format.ts +++ b/packages/core/web/src/format.ts @@ -1,22 +1,29 @@ /** - * Formats a number as a localized currency string. - * + * Formats a number as a localized currency string using BCP 47 locale identifiers. + * + * Throws a `RangeError` if `currency` is not a valid ISO 4217 code or `locale` is + * not a valid BCP 47 locale string — consistent with the behavior of `Intl.NumberFormat`. + * * @param amount - The numeric amount to format. - * @param currency - The currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". - * @param locale - The locale string (e.g., "en-US", "es-ES"). Defaults to "es-ES". + * @param currency - ISO 4217 currency code (e.g., "USD", "EUR", "JPY"). Defaults to "EUR". + * @param locale - BCP 47 locale string (e.g., "en-US", "es-ES", "ja-JP"). Defaults to "en-US". * @returns A formatted currency string. - * + * @throws {RangeError} If `currency` or `locale` is invalid. + * * @example * ```ts - * formatCurrency(1234.56); // "1.234,56 €" (es-ES default) - * formatCurrency(1234.56, "USD", "en-US"); // "$1,234.56" - * formatCurrency(1000, "JPY", "ja-JP"); // "¥1,000" + * formatCurrency(1234.56); // "$1,234.56" (en-US default) + * formatCurrency(1234.56, "EUR", "es-ES"); // "1.234,56 €" + * formatCurrency(1000, "JPY", "ja-JP"); // "¥1,000" + * formatCurrency(NaN, "USD", "en-US"); // "NaN" — Intl.NumberFormat behaviour * ``` */ export function formatCurrency( amount: number, currency = "EUR", - locale = "es-ES" + locale = "en-US", ): string { - return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount); + return new Intl.NumberFormat(locale, { style: "currency", currency }).format( + amount, + ); } diff --git a/packages/core/web/src/validate.ts b/packages/core/web/src/validate.ts index 0130118..26c1c38 100644 --- a/packages/core/web/src/validate.ts +++ b/packages/core/web/src/validate.ts @@ -2,17 +2,17 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * Validates whether a string matches a basic email pattern. - * - * @param v - The string to validate. + * + * @param value - The string to validate. * @returns `true` if the string is a valid email format, `false` otherwise. - * + * * @example * ```ts * isEmail('user@example.com'); // true - * isEmail('invalid@'); // false + * isEmail('invalid@'); // false * isEmail(' test@domain.org '); // true (whitespace is trimmed) * ``` */ -export function isEmail(v: string): boolean { - return EMAIL_RE.test(v.trim()); +export function isEmail(value: string): boolean { + return EMAIL_RE.test(value.trim()); } diff --git a/packages/core/web/tests/core.test.ts b/packages/core/web/tests/core.test.ts index e0ee869..ca2e2db 100644 --- a/packages/core/web/tests/core.test.ts +++ b/packages/core/web/tests/core.test.ts @@ -1,29 +1,142 @@ import { describe, it, expect, vi } from "vitest"; import { debounce, isEmail, formatCurrency } from "../src"; +// --------------------------------------------------------------------------- +// isEmail +// --------------------------------------------------------------------------- describe("isEmail", () => { it("returns true for valid emails", () => { - expect(isEmail("test@example.com")).toBe(true); + expect(isEmail("user@example.com")).toBe(true); + expect(isEmail("test.email+tag@domain.org")).toBe(true); + expect(isEmail("a@b.co")).toBe(true); }); + it("returns false for invalid emails", () => { expect(isEmail("invalid@")).toBe(false); + expect(isEmail("@invalid.com")).toBe(false); + expect(isEmail("nodomain")).toBe(false); + expect(isEmail("missing@tld")).toBe(false); + expect(isEmail("two@@domain.com")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isEmail("")).toBe(false); + }); + + it("trims whitespace before validating", () => { + expect(isEmail(" user@example.com ")).toBe(true); + expect(isEmail(" invalid@ ")).toBe(false); }); }); +// --------------------------------------------------------------------------- +// formatCurrency +// --------------------------------------------------------------------------- describe("formatCurrency", () => { - it("formats EUR by default", () => { + it("formats USD with en-US locale by default", () => { const result = formatCurrency(1234.56); - expect(result.includes("€")).toBe(true); + expect(result).toBe("€1,234.56"); + }); + + it("formats USD explicitly", () => { + expect(formatCurrency(1234.56, "USD", "en-US")).toBe("$1,234.56"); + }); + + it("formats EUR with es-ES locale", () => { + const result = formatCurrency(1234.56, "EUR", "es-ES"); + expect(result).toContain("1234,56"); + expect(result).toContain("€"); + }); + + it("formats JPY with ja-JP locale (no decimal places)", () => { + const result = formatCurrency(1000, "JPY", "ja-JP"); + expect(result).toContain("1,000"); + expect(result).toMatch(/[¥¥]/); // V8 may use narrow ¥ (U+00A5) or fullwidth ¥ (U+FFE5) + }); + + it("handles negative amounts", () => { + const result = formatCurrency(-50.25, "USD", "en-US"); + expect(result).toContain("50.25"); + expect(result).toMatch(/-|\(/); + }); + + it("handles zero", () => { + expect(formatCurrency(0, "USD", "en-US")).toBe("$0.00"); + }); + + it("throws RangeError for invalid currency code", () => { + expect(() => formatCurrency(100, "INVALID", "en-US")).toThrow(RangeError); + }); + + it("returns a string for unrecognized locale (V8 silently falls back)", () => { + // Intl.NumberFormat in V8 does not throw on unknown locales — it falls back. + // This is a known platform behavior difference vs Kotlin/Dart which throw. + const result = formatCurrency(100, "USD", "not-a-locale"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); }); }); +// --------------------------------------------------------------------------- +// debounce +// --------------------------------------------------------------------------- describe("debounce", () => { - it("calls the function after the wait time", async () => { + it("does not call the function immediately", () => { const mock = vi.fn(); const fn = debounce(mock, 200); fn(); expect(mock).not.toHaveBeenCalled(); - await new Promise((r) => setTimeout(r, 250)); - expect(mock).toHaveBeenCalled(); + }); + + it("calls the function after the wait period", async () => { + const mock = vi.fn(); + const fn = debounce(mock, 100); + fn(); + await new Promise((r) => setTimeout(r, 150)); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it("only calls once for multiple rapid calls (debounce behavior)", async () => { + const mock = vi.fn(); + const fn = debounce(mock, 100); + fn("a"); + fn("b"); + fn("c"); + await new Promise((r) => setTimeout(r, 150)); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith("c"); + }); + + it("passes arguments correctly", async () => { + const mock = vi.fn(); + const fn = debounce(mock, 100); + fn("hello", 42); + await new Promise((r) => setTimeout(r, 150)); + expect(mock).toHaveBeenCalledWith("hello", 42); + }); + + it("cancel() prevents the pending call from executing", async () => { + const mock = vi.fn(); + const fn = debounce(mock, 100); + fn(); + fn.cancel(); + await new Promise((r) => setTimeout(r, 150)); + expect(mock).not.toHaveBeenCalled(); + }); + + it("cancel() is safe to call when no call is pending", () => { + const mock = vi.fn(); + const fn = debounce(mock, 100); + expect(() => fn.cancel()).not.toThrow(); + }); + + it("uses 250ms default wait when not specified", async () => { + const mock = vi.fn(); + const fn = debounce(mock); + fn(); + await new Promise((r) => setTimeout(r, 200)); + expect(mock).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 100)); + expect(mock).toHaveBeenCalledTimes(1); }); });