diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 8dab57cdf..362f12e7b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -247,6 +247,94 @@ jobs: name: "api-compat-reports" path: build/api-compat/** + kotlin-migration-progress: + name: "Kotlin Migration Progress" + # Posts the Java-to-Kotlin conversion numbers on every pull request. The last step fails + # when a pull request adds Java to the published modules without the allow-new-java label. + if: github.event_name == 'pull_request' + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + COMMENT_IDENTIFIER: "" + steps: + # The default pull_request ref is the merge result; see the size-report job below for why + # that is the right side to measure and what happens on a conflicted pull request. + - name: "Checkout PR head" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: "Checkout base branch" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + path: base + - name: "Test the progress script" + run: python3 -m unittest scripts/test_kotlin_migration_progress.py + - name: "Measure head and base" + id: measure + run: | + set -euo pipefail + read_metric() { grep "^$2=" "$1" | cut -d= -f2; } + python3 scripts/kotlin_migration_progress.py --format env > "${RUNNER_TEMP}/head.env" + python3 scripts/kotlin_migration_progress.py > "${RUNNER_TEMP}/head.md" + # The base is measured with the head's facade list, so editing the list cannot masquerade as progress. + python3 scripts/kotlin_migration_progress.py --root base --facades scripts/kotlin-migration-facades.txt --format env > "${RUNNER_TEMP}/base.env" + head_left=$(read_metric "${RUNNER_TEMP}/head.env" JAVA_LEFT_LOC) + base_left=$(read_metric "${RUNNER_TEMP}/base.env" JAVA_LEFT_LOC) + head_java=$(read_metric "${RUNNER_TEMP}/head.env" JAVA_LOC) + base_java=$(read_metric "${RUNNER_TEMP}/base.env" JAVA_LOC) + delta_left=$((head_left - base_left)) + delta_java=$((head_java - base_java)) + if [ "${delta_left}" -lt 0 ]; then + headline="This pull request converts **$((-delta_left)) lines** of Java to Kotlin." + elif [ "${delta_java}" -gt 0 ]; then + headline=":warning: This pull request **adds ${delta_java} lines** of Java to the published modules." + else + headline="No change to the Java left to convert." + fi + { + echo "${COMMENT_IDENTIFIER}" + echo "### Kotlin migration progress" + echo + echo "${headline}" + echo + cat "${RUNNER_TEMP}/head.md" + } > "${RUNNER_TEMP}/comment.md" + cat "${RUNNER_TEMP}/comment.md" >> "${GITHUB_STEP_SUMMARY}" + echo "delta_java=${delta_java}" >> "${GITHUB_OUTPUT}" + # A pull_request from a fork gets a read-only token, so commenting would 403. + - name: "Find existing comment" + id: existing + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: ${{ env.COMMENT_IDENTIFIER }} + - name: "Create or update PR comment" + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 + with: + comment-id: ${{ steps.existing.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body-path: ${{ runner.temp }}/comment.md + - name: "Ratchet: no new Java in the published modules" + if: > + steps.measure.outputs.delta_java > 0 && + !contains(github.event.pull_request.labels.*.name, 'allow-new-java') + env: + DELTA_JAVA: ${{ steps.measure.outputs.delta_java }} + run: | + echo "This pull request adds ${DELTA_JAVA} lines of Java to android-core or android-kit-base." >&2 + echo "New code in these modules is written in Kotlin. If the Java is unavoidable, add the" >&2 + echo "allow-new-java label and explain why in the description." >&2 + exit 1 + automerge-dependabot: name: "Save PR Number for Dependabot Automerge" if: github.event_name == 'pull_request' diff --git a/AGENTS.md b/AGENTS.md index 7512983c8..eb4f4ae4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ JDK 17 — `gradle.properties` sets `JAVA_VERSION` and every CI job installs Zul intentional change and explain the diff in the PR. `scripts/check_api_dump.py --base origin/main` tells you whether a changed class is a frozen contract (see `scripts/api-frozen-internals.txt`). - Android lint — `./gradlew lint`; Kotlin lint — `./gradlew ktlintCheck` +- Migration progress — `scripts/kotlin_migration_progress.py` prints Java left to convert and the + Kotlin share of `android-core` and `android-kit-base` (CI job _Kotlin Migration Progress_). - Binary compatibility with the last release — `scripts/api_compat_report.py` builds the release AARs and runs japicmp against the latest version on Maven Central (CI job _Binary Compatibility_). It compares what ships after R8, so it is the check that matters for consumers. diff --git a/docs/kotlin-migration/PLAYBOOK.md b/docs/kotlin-migration/PLAYBOOK.md index 624f0c7ce..fec9e5d1d 100644 --- a/docs/kotlin-migration/PLAYBOOK.md +++ b/docs/kotlin-migration/PLAYBOOK.md @@ -193,4 +193,6 @@ Whoever runs `Release – Draft` for unrelated work while migration pull request - Titles follow the house convention: `refactor(core): convert internal.database tables to Kotlin`, `build: …`, `ci: …`, `test: …`. A pure conversion has no `CHANGELOG.md` entry. - A pull request that adds Java to `android-core/src/main` or `android-kit-base/src/main` needs a - stated reason; the direction of travel is Kotlin. + stated reason; the direction of travel is Kotlin. The **Kotlin Migration Progress** job posts the + conversion numbers on every pull request and fails when Java is added without the `allow-new-java` + label. diff --git a/docs/kotlin-migration/TRACKER.md b/docs/kotlin-migration/TRACKER.md index aa11aa6a0..281a9e048 100644 --- a/docs/kotlin-migration/TRACKER.md +++ b/docs/kotlin-migration/TRACKER.md @@ -21,15 +21,16 @@ Java that converts: **16,550 LOC**. Java that stays by design (public facade, ki No product code. Risk class L throughout. -| Done | PR | Title | Notes | -| ---- | --- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| [ ] | 0.1 | `ci: report binary compatibility against the last published release` | `scripts/api_compat_report.py`; job **Binary Compatibility** | -| [ ] | 0.2 | `build: add public API dumps with binary-compatibility-validator` | `./gradlew apiCheck` in the Unit Tests job; `scripts/check_api_dump.py` | -| [ ] | 0.3 | `test: add Java and Kotlin consumer fixtures built against the published artifacts` | `settings-compat.gradle`, `compat/` | -| [ ] | 0.4 | `test: replace PowerMock with Mockito 5 in core and kit-base unit tests` | Prerequisite for converting classes the unit tests mock | -| [ ] | 0.5 | `test: expose explicit test seams for package-private core internals` | Prerequisite for stacks C and D | -| [ ] | 0.6 | `build(lint): build the custom lint jar from merged Java and Kotlin classes` | Prerequisite for converting `Logger` | -| [ ] | 0.7 | `docs: add the Kotlin migration playbook and tracker` | This document | +| Done | PR | Title | Notes | +| ---- | --- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [ ] | 0.1 | `ci: report binary compatibility against the last published release` | `scripts/api_compat_report.py`; job **Binary Compatibility** | +| [ ] | 0.2 | `build: add public API dumps with binary-compatibility-validator` | `./gradlew apiCheck` in the Unit Tests job; `scripts/check_api_dump.py` | +| [ ] | 0.3 | `test: add Java and Kotlin consumer fixtures built against the published artifacts` | `settings-compat.gradle`, `compat/` | +| [ ] | 0.4 | `test: replace PowerMock with Mockito 5 in core and kit-base unit tests` | Prerequisite for converting classes the unit tests mock | +| [ ] | 0.5 | `test: expose explicit test seams for package-private core internals` | Prerequisite for stacks C and D | +| [ ] | 0.6 | `build(lint): build the custom lint jar from merged Java and Kotlin classes` | Prerequisite for converting `Logger` | +| [ ] | 0.7 | `docs: add the Kotlin migration playbook and tracker` | This document | +| [ ] | 0.8 | `ci: report Java to Kotlin migration progress on pull requests` | `scripts/kotlin_migration_progress.py`; job **Kotlin Migration Progress**; `allow-new-java` ratchet | ## Phase 1 · Build hygiene @@ -132,18 +133,30 @@ zero frozen-class diff in `apiCheck` and an empty Binary Compatibility report. | [ ] | 3.2 | `kits/KitManagerImpl` | 1,441 | H | Keep the Java declaration; move the body | | [ ] | 3.3 | `internal/MParticleJSInterface` | 840 | H | Leave in Java | -## Checkpoints - -QA points, not releases. Record the result of each in this section when it is reached. - -| Point | After | Check | -| ----- | ----------------------------------- | ----------------------------------------------------------------------------------------------------- | -| M0 | Phase 0 and PR 1.1 | A deliberate removal of a public method on a scratch branch fails `apiCheck` and Binary Compatibility | -| M1 | Stacks A and D | Offline queue across process death, sessions, uploads | -| M2 | Stacks B, C and G | Identity flows, remote config, certificate pinning against production, push registration | -| M3 | Stacks E, F and H; Phase 1 complete | Full checklist; one third-party kit and the Rokt kit end to end on a minified consumer build | -| Gate | Before Phase 3 | Per-file go/no-go with the consumer fixture results | -| M4 | Phase 3 | Full checklist; the migration's release | +## Checkpoints and expected numbers + +QA points, not releases. The **Kotlin Migration Progress** job reports "Java left to convert" and +"Kotlin share" on every pull request; the expected values below assume the stacks land in the +recommended order and that converted Java shrinks to roughly 65–80% of its length as Kotlin. +"Conversion progress" is exact: it is the share of the 16,550 baseline Java LOC that has been +converted. + +| Point | After | Java left (LOC) | Conversion progress | Kotlin share (est.) | Check | +| ----- | ----------------------------------- | --------------: | ------------------: | ------------------: | ----------------------------------------------------------------------------------------------------- | +| M0 | Phase 0 and PR 1.1 | 16,550 | 0% | 12% | A deliberate removal of a public method on a scratch branch fails `apiCheck` and Binary Compatibility | +| | Stack A | 14,062 | 15% | 18–19% | | +| M1 | Stacks A and D | 11,336 | 32% | 24–26% | Offline queue across process death, sessions, uploads | +| | Stack B | 10,066 | 39% | 27–29% | | +| | Stack C | 9,159 | 45% | 30–32% | | +| M2 | Stacks B, C and G | 8,143 | 51% | 32–35% | Identity flows, remote config, certificate pinning against production, push registration | +| | Stack E | 7,141 | 57% | 35–38% | | +| | Stack F | 5,922 | 64% | 38–41% | | +| M3 | Stacks E, F and H; Phase 1 complete | 3,350 | 80% | 46–50% | Full checklist; one third-party kit and the Rokt kit end to end on a minified consumer build | +| Gate | Before Phase 3 | 3,350 | 80% | 46–50% | Per-file go/no-go with the consumer fixture results | +| M4 | Phase 3 | 0 | 100% | 56–60% | Full checklist; the migration's release | + +The Kotlin share stops well short of 100% by design: 11,287 LOC of Java facade stay Java in 6.x. +Facade thinning (an optional follow-up) would raise it further without changing the goal metric. ## Optional follow-ups diff --git a/scripts/kotlin-migration-facades.txt b/scripts/kotlin-migration-facades.txt new file mode 100644 index 000000000..ffe2ee91a --- /dev/null +++ b/scripts/kotlin-migration-facades.txt @@ -0,0 +1,74 @@ +# Java files that stay Java by design for the duration of the migration. +# +# These declare the public API that customers and kit authors compile against +# (see docs/kotlin-migration/PLAYBOOK.md, "Scope"), plus package-info.java, +# which has no Kotlin equivalent. scripts/kotlin_migration_progress.py counts +# them separately so that "Java left to convert" can honestly reach zero. +# +# Adding a file here is an API-surface decision that needs a maintainer's +# review, not just the author's. One repository-relative path per line. +android-core/src/main/java/com/mparticle/AttributionError.java +android-core/src/main/java/com/mparticle/AttributionListener.java +android-core/src/main/java/com/mparticle/AttributionResult.java +android-core/src/main/java/com/mparticle/BaseEvent.java +android-core/src/main/java/com/mparticle/Configuration.java +android-core/src/main/java/com/mparticle/MPEvent.java +android-core/src/main/java/com/mparticle/MPReceiver.java +android-core/src/main/java/com/mparticle/MPService.java +android-core/src/main/java/com/mparticle/MParticle.java +android-core/src/main/java/com/mparticle/MParticleOptions.java +android-core/src/main/java/com/mparticle/MParticleTask.java +android-core/src/main/java/com/mparticle/SdkListener.java +android-core/src/main/java/com/mparticle/Session.java +android-core/src/main/java/com/mparticle/commerce/CommerceEvent.java +android-core/src/main/java/com/mparticle/commerce/Impression.java +android-core/src/main/java/com/mparticle/commerce/Product.java +android-core/src/main/java/com/mparticle/commerce/Promotion.java +android-core/src/main/java/com/mparticle/commerce/TransactionAttributes.java +android-core/src/main/java/com/mparticle/commerce/package-info.java +android-core/src/main/java/com/mparticle/consent/CCPAConsent.java +android-core/src/main/java/com/mparticle/consent/ConsentInstance.java +android-core/src/main/java/com/mparticle/consent/ConsentState.java +android-core/src/main/java/com/mparticle/consent/GDPRConsent.java +android-core/src/main/java/com/mparticle/consent/package-info.java +android-core/src/main/java/com/mparticle/identity/AliasRequest.java +android-core/src/main/java/com/mparticle/identity/AliasResponse.java +android-core/src/main/java/com/mparticle/identity/BaseIdentityTask.java +android-core/src/main/java/com/mparticle/identity/IdentityApi.java +android-core/src/main/java/com/mparticle/identity/IdentityApiRequest.java +android-core/src/main/java/com/mparticle/identity/IdentityApiResult.java +android-core/src/main/java/com/mparticle/identity/IdentityHttpResponse.java +android-core/src/main/java/com/mparticle/identity/IdentityStateListener.java +android-core/src/main/java/com/mparticle/identity/MParticleUser.java +android-core/src/main/java/com/mparticle/identity/TaskFailureListener.java +android-core/src/main/java/com/mparticle/identity/TaskSuccessListener.java +android-core/src/main/java/com/mparticle/identity/package-info.java +android-core/src/main/java/com/mparticle/internal/package-info.java +android-core/src/main/java/com/mparticle/media/MPMediaAPI.java +android-core/src/main/java/com/mparticle/media/MediaCallbacks.java +android-core/src/main/java/com/mparticle/media/package-info.java +android-core/src/main/java/com/mparticle/messaging/InstanceIdService.java +android-core/src/main/java/com/mparticle/messaging/MPMessagingAPI.java +android-core/src/main/java/com/mparticle/messaging/MPMessagingRouter.java +android-core/src/main/java/com/mparticle/messaging/MessagingConfigCallbacks.java +android-core/src/main/java/com/mparticle/messaging/ProviderCloudMessage.java +android-core/src/main/java/com/mparticle/messaging/PushAnalyticsReceiver.java +android-core/src/main/java/com/mparticle/messaging/PushAnalyticsReceiverCallback.java +android-core/src/main/java/com/mparticle/messaging/package-info.java +android-core/src/main/java/com/mparticle/networking/BaseNetworkConnection.java +android-core/src/main/java/com/mparticle/networking/Certificate.java +android-core/src/main/java/com/mparticle/networking/DomainMapping.java +android-core/src/main/java/com/mparticle/networking/MPConnection.java +android-core/src/main/java/com/mparticle/networking/MPUrl.java +android-core/src/main/java/com/mparticle/networking/NetworkOptions.java +android-core/src/main/java/com/mparticle/package-info.java +android-core/src/main/java/com/mparticle/segmentation/Segment.java +android-core/src/main/java/com/mparticle/segmentation/SegmentListener.java +android-core/src/main/java/com/mparticle/segmentation/SegmentMembership.java +android-core/src/main/java/com/mparticle/segmentation/package-info.java +android-kit-base/src/main/java/com/mparticle/kits/CommerceEventUtils.java +android-kit-base/src/main/java/com/mparticle/kits/FilteredIdentityApiRequest.java +android-kit-base/src/main/java/com/mparticle/kits/FilteredMParticleUser.java +android-kit-base/src/main/java/com/mparticle/kits/KitIntegration.java +android-kit-base/src/main/java/com/mparticle/kits/KitUtils.java +android-kit-base/src/main/java/com/mparticle/kits/ReportingMessage.java diff --git a/scripts/kotlin_migration_progress.py b/scripts/kotlin_migration_progress.py new file mode 100755 index 000000000..dc99579d8 --- /dev/null +++ b/scripts/kotlin_migration_progress.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Report Java to Kotlin migration progress for the published SDK modules. + +Measures ``android-core/src/main`` and ``android-kit-base/src/main`` only: +test source sets, kits and sample apps are out of scope. Java files listed in +``scripts/kotlin-migration-facades.txt`` stay Java by design (the public API +customers and kit authors compile against, plus ``package-info.java``); they +are counted separately so that **Java left to convert** can honestly reach +zero. + +Three numbers are reported: + +* **Java left to convert** (LOC): the goal metric; it only goes down. +* **Conversion progress**: ``1 - left / BASELINE_JAVA_LEFT``, where the + baseline was measured when the migration started. Recompute it if the + facade list changes. +* **Kotlin share**: Kotlin LOC over all ``src/main`` LOC. Kotlin is terser + than Java, so this rises more slowly than conversion progress. + +Usage:: + + scripts/kotlin_migration_progress.py [--root DIR] [--format markdown|env] + [--facades FILE] + +The ``env`` format prints ``KEY=value`` lines for CI. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import sys +from dataclasses import dataclass +from pathlib import Path + +MODULES = ("android-core/src/main", "android-kit-base/src/main") +FACADE_LIST = Path("scripts/kotlin-migration-facades.txt") + +# Java LOC outside the facade list at main 4ee3ba4c (version 6.1.2). +BASELINE_JAVA_LEFT = 16_550 + + +@dataclass +class ModuleStats: + module: str + kotlin_loc: int = 0 + java_loc: int = 0 + facade_loc: int = 0 + java_files: int = 0 + kotlin_files: int = 0 + + @property + def java_left(self) -> int: + return self.java_loc - self.facade_loc + + @property + def kotlin_share(self) -> float: + total = self.kotlin_loc + self.java_loc + return 100.0 if total == 0 else 100.0 * self.kotlin_loc / total + + +def count_lines(path: Path) -> int: + """Newline count, matching ``wc -l`` and the figures quoted in the tracker.""" + return path.read_bytes().count(b"\n") + + +def load_facades(path: Path) -> list[str]: + if not path.exists(): + return [] + entries: list[str] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + entry = raw.split("#", 1)[0].strip() + if entry: + entries.append(entry) + return entries + + +def is_facade(relative_path: str, facades: list[str]) -> bool: + return any(fnmatch.fnmatchcase(relative_path, pattern) for pattern in facades) + + +def measure(root: Path, facades: list[str]) -> list[ModuleStats]: + """Measure every module under ``root``; a missing module directory is an error, never a zero.""" + results: list[ModuleStats] = [] + for module in MODULES: + stats = ModuleStats(module=module.split("/", 1)[0]) + module_dir = root / module + if not module_dir.is_dir(): + raise FileNotFoundError(f"{module_dir} is not a directory; is --root the repository root?") + for path in sorted(module_dir.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + if path.suffix == ".java": + lines = count_lines(path) + stats.java_loc += lines + stats.java_files += 1 + if is_facade(relative, facades): + stats.facade_loc += lines + elif path.suffix == ".kt": + stats.kotlin_loc += count_lines(path) + stats.kotlin_files += 1 + results.append(stats) + return results + + +def totals(results: list[ModuleStats]) -> ModuleStats: + total = ModuleStats(module="total") + for stats in results: + total.kotlin_loc += stats.kotlin_loc + total.java_loc += stats.java_loc + total.facade_loc += stats.facade_loc + total.java_files += stats.java_files + total.kotlin_files += stats.kotlin_files + return total + + +def conversion_progress(java_left: int) -> float: + if BASELINE_JAVA_LEFT <= 0: + return 100.0 + return max(0.0, min(100.0, 100.0 * (1 - java_left / BASELINE_JAVA_LEFT))) + + +def render_markdown(results: list[ModuleStats]) -> str: + total = totals(results) + lines = [ + "| Module | Kotlin LOC | Java LOC | Java staying (facade) | Java left to convert | Kotlin share |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ] + for stats in results: + lines.append( + f"| `{stats.module}` | {stats.kotlin_loc:,} | {stats.java_loc:,} | {stats.facade_loc:,} " + f"| **{stats.java_left:,}** | {stats.kotlin_share:.1f}% |" + ) + lines.append( + f"| **Total** | **{total.kotlin_loc:,}** | **{total.java_loc:,}** | **{total.facade_loc:,}** " + f"| **{total.java_left:,}** | **{total.kotlin_share:.1f}%** |" + ) + lines.append("") + lines.append( + f"**Conversion progress: {conversion_progress(total.java_left):.1f}%** " + f"({total.java_left:,} of {BASELINE_JAVA_LEFT:,} baseline Java LOC left to convert). " + f"{total.java_files} Java files and {total.kotlin_files} Kotlin files in scope." + ) + return "\n".join(lines) + + +def render_env(results: list[ModuleStats]) -> str: + total = totals(results) + return "\n".join( + [ + f"KOTLIN_LOC={total.kotlin_loc}", + f"JAVA_LOC={total.java_loc}", + f"JAVA_FACADE_LOC={total.facade_loc}", + f"JAVA_LEFT_LOC={total.java_left}", + f"JAVA_FILES={total.java_files}", + f"KOTLIN_FILES={total.kotlin_files}", + f"KOTLIN_SHARE_PCT={total.kotlin_share:.1f}", + f"CONVERSION_PCT={conversion_progress(total.java_left):.1f}", + ] + ) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--root", default=".", help="repository root to measure (default: current directory)") + parser.add_argument("--format", choices=("markdown", "env"), default="markdown") + parser.add_argument("--facades", help="facade list to apply (default: scripts/kotlin-migration-facades.txt under --root)") + args = parser.parse_args(argv) + + root = Path(args.root).resolve() + facade_path = Path(args.facades).resolve() if args.facades else root / FACADE_LIST + try: + results = measure(root, load_facades(facade_path)) + except FileNotFoundError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(render_env(results) if args.format == "env" else render_markdown(results)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_kotlin_migration_progress.py b/scripts/test_kotlin_migration_progress.py new file mode 100644 index 000000000..bd8782ccf --- /dev/null +++ b/scripts/test_kotlin_migration_progress.py @@ -0,0 +1,103 @@ +"""Unit tests for scripts/kotlin_migration_progress.py. + +Run with ``python3 -m unittest scripts/test_kotlin_migration_progress.py``. +""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "kotlin_migration_progress", Path(__file__).resolve().parent / "kotlin_migration_progress.py" +) +progress = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = progress +_SPEC.loader.exec_module(progress) + + +def _write(root: Path, relative: str, lines: int) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(f"line {i}" for i in range(lines)) + "\n", encoding="utf-8") + + +class MeasureTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + _write(self.root, "android-core/src/main/java/com/mparticle/MParticle.java", 100) + _write(self.root, "android-core/src/main/java/com/mparticle/internal/ConfigManager.java", 40) + _write(self.root, "android-core/src/main/kotlin/com/mparticle/internal/Constants.kt", 30) + _write(self.root, "android-core/src/main/java/com/mparticle/package-info.java", 3) + _write(self.root, "android-core/src/test/java/com/mparticle/Ignored.java", 500) + _write(self.root, "android-kit-base/src/main/java/com/mparticle/kits/KitIntegration.java", 50) + _write(self.root, "android-kit-base/src/main/kotlin/com/mparticle/kits/KitOptions.kt", 20) + _write( + self.root, + "scripts/kotlin-migration-facades.txt", + 0, + ) + (self.root / "scripts/kotlin-migration-facades.txt").write_text( + "# comment\n" + "android-core/src/main/java/com/mparticle/MParticle.java\n" + "*/package-info.java\n" + "android-kit-base/src/main/java/com/mparticle/kits/KitIntegration.java\n", + encoding="utf-8", + ) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_counts_only_main_source_sets(self) -> None: + facades = progress.load_facades(self.root / "scripts/kotlin-migration-facades.txt") + results = progress.measure(self.root, facades) + core, kit = results + self.assertEqual(core.java_loc, 143) + self.assertEqual(core.kotlin_loc, 30) + self.assertEqual(core.java_files, 3) + self.assertEqual(kit.java_loc, 50) + self.assertEqual(kit.kotlin_loc, 20) + + def test_facade_and_package_info_are_excluded_from_java_left(self) -> None: + facades = progress.load_facades(self.root / "scripts/kotlin-migration-facades.txt") + core, kit = progress.measure(self.root, facades) + self.assertEqual(core.facade_loc, 103) + self.assertEqual(core.java_left, 40) + self.assertEqual(kit.facade_loc, 50) + self.assertEqual(kit.java_left, 0) + + def test_totals_and_renderers(self) -> None: + facades = progress.load_facades(self.root / "scripts/kotlin-migration-facades.txt") + results = progress.measure(self.root, facades) + total = progress.totals(results) + self.assertEqual(total.java_left, 40) + self.assertAlmostEqual(total.kotlin_share, 100.0 * 50 / 243, places=3) + env = dict(line.split("=", 1) for line in progress.render_env(results).splitlines()) + self.assertEqual(env["JAVA_LEFT_LOC"], "40") + self.assertEqual(env["KOTLIN_FILES"], "2") + markdown = progress.render_markdown(results) + self.assertIn("| `android-core` | 30 | 143 | 103 | **40** |", markdown) + self.assertIn("Conversion progress:", markdown) + + def test_conversion_progress_is_clamped(self) -> None: + self.assertEqual(progress.conversion_progress(progress.BASELINE_JAVA_LEFT), 0.0) + self.assertEqual(progress.conversion_progress(0), 100.0) + self.assertEqual(progress.conversion_progress(progress.BASELINE_JAVA_LEFT * 2), 0.0) + + def test_missing_facade_list_counts_everything_as_convertible(self) -> None: + core, _ = progress.measure(self.root, progress.load_facades(self.root / "missing.txt")) + self.assertEqual(core.java_left, core.java_loc) + + def test_missing_module_directory_is_an_error_not_a_zero(self) -> None: + with tempfile.TemporaryDirectory() as empty: + with self.assertRaises(FileNotFoundError): + progress.measure(Path(empty), []) + self.assertEqual(progress.main(["--root", empty, "--format", "env"]), 1) + + +if __name__ == "__main__": + unittest.main()