From f50d3099ce4bc25d034fbd2b8508c2d6da5fd2de Mon Sep 17 00:00:00 2001 From: lleviathe <95321986+lleviathe@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:10:51 +0400 Subject: [PATCH] test(smoke): smoke-test PHP on a php-app fixture (#2189) PHP had no regression coverage. Adds a php-app fixture (Composer + Pest) with language, run and debug smoke cases, and a php entry in the Language smoke workflow: PHP 8.5 with Xdebug via setup-php, Intelephense pinned like the other servers. Other languages' selectors are unchanged. Co-authored-by: lleviathe <95321986+lleviathe@users.noreply.github.com> --- .github/workflows/language-smoke.yml | 10 +- crates/ide-host/tests/language_smoke.rs | 160 ++++++++++++------ fixtures/README.md | 25 +-- fixtures/php-app/.gitignore | 4 + fixtures/php-app/bin/quote.php | 46 +++++ fixtures/php-app/composer.json | 34 ++++ fixtures/php-app/phpstan.neon | 5 + fixtures/php-app/phpunit.xml | 16 ++ fixtures/php-app/pint.json | 19 +++ fixtures/php-app/src/Shipping/Charges.php | 23 +++ .../php-app/src/Shipping/CustomsPolicy.php | 13 ++ fixtures/php-app/src/Shipping/Dimensions.php | 18 ++ .../src/Shipping/InvalidStatusTransition.php | 15 ++ fixtures/php-app/src/Shipping/Parcel.php | 40 +++++ .../php-app/src/Shipping/ParcelStatus.php | 33 ++++ fixtures/php-app/src/Shipping/Route.php | 24 +++ fixtures/php-app/src/Shipping/Shipment.php | 73 ++++++++ fixtures/php-app/src/Shipping/Tariff.php | 18 ++ .../php-app/src/Shipping/ThresholdCustoms.php | 64 +++++++ fixtures/php-app/tests/ArchTest.php | 22 +++ .../php-app/tests/Shipping/ParcelTest.php | 42 +++++ .../php-app/tests/Shipping/ShipmentTest.php | 36 ++++ .../tests/Shipping/ThresholdCustomsTest.php | 23 +++ .../tests/TestSupport/ParcelFactory.php | 16 ++ knip.ts | 5 +- smoke/debug.test.ts | 9 + smoke/run.test.ts | 13 ++ 27 files changed, 740 insertions(+), 66 deletions(-) create mode 100644 fixtures/php-app/.gitignore create mode 100644 fixtures/php-app/bin/quote.php create mode 100644 fixtures/php-app/composer.json create mode 100644 fixtures/php-app/phpstan.neon create mode 100644 fixtures/php-app/phpunit.xml create mode 100644 fixtures/php-app/pint.json create mode 100644 fixtures/php-app/src/Shipping/Charges.php create mode 100644 fixtures/php-app/src/Shipping/CustomsPolicy.php create mode 100644 fixtures/php-app/src/Shipping/Dimensions.php create mode 100644 fixtures/php-app/src/Shipping/InvalidStatusTransition.php create mode 100644 fixtures/php-app/src/Shipping/Parcel.php create mode 100644 fixtures/php-app/src/Shipping/ParcelStatus.php create mode 100644 fixtures/php-app/src/Shipping/Route.php create mode 100644 fixtures/php-app/src/Shipping/Shipment.php create mode 100644 fixtures/php-app/src/Shipping/Tariff.php create mode 100644 fixtures/php-app/src/Shipping/ThresholdCustoms.php create mode 100644 fixtures/php-app/tests/ArchTest.php create mode 100644 fixtures/php-app/tests/Shipping/ParcelTest.php create mode 100644 fixtures/php-app/tests/Shipping/ShipmentTest.php create mode 100644 fixtures/php-app/tests/Shipping/ThresholdCustomsTest.php create mode 100644 fixtures/php-app/tests/TestSupport/ParcelFactory.php diff --git a/.github/workflows/language-smoke.yml b/.github/workflows/language-smoke.yml index b349d2d53..6fac6763b 100644 --- a/.github/workflows/language-smoke.yml +++ b/.github/workflows/language-smoke.yml @@ -47,6 +47,7 @@ env: JDTLS: 1.63.0-202610191322 KOTLIN_LSP: 263.7084.0 GRADLE: 8.14.3 + INTELEPHENSE: 1.18.5 jobs: smoke: @@ -54,7 +55,7 @@ jobs: strategy: fail-fast: false matrix: - language: [typescript, python, rust, go, java, kotlin] + language: [typescript, python, rust, go, java, kotlin, php] runs-on: ubuntu-22.04 timeout-minutes: 40 steps: @@ -108,6 +109,13 @@ jobs: ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + # Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke. + - if: matrix.language == 'php' + uses: shivammathur/setup-php@v2 + with: { php-version: "8.5", coverage: xdebug, tools: composer } + - if: matrix.language == 'php' + run: npm install --global "intelephense@$INTELEPHENSE" + - run: cargo test --locked -p ide-host --test language_smoke --no-run - run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }} - if: matrix.language == 'java' diff --git a/crates/ide-host/tests/language_smoke.rs b/crates/ide-host/tests/language_smoke.rs index ec9701397..76a2b51c7 100644 --- a/crates/ide-host/tests/language_smoke.rs +++ b/crates/ide-host/tests/language_smoke.rs @@ -59,16 +59,15 @@ struct Checks { hover: (At, &'static str), /// Completion at the end of the typed line offers this item. completion: (Typed, &'static str), - /// The typed line is reported with this diagnostic; the quick fix with this title makes the - /// file contain the last text. + /// The typed line is reported with a diagnostic, and its quick fix applies. quick_fix: QuickFix, /// With the word selected, this intention is offered with an edit or command. - intention: (At, &'static str), + intention: Option<(At, &'static str)>, /// Formatting turns the typed line into the expected one. format: (Typed, &'static str), indent: Indent, /// Renaming the symbol to the new name edits these files. - rename: (At, &'static str, &'static [&'static str]), + rename: Option<(At, &'static str, &'static [&'static str])>, /// Parameter info in a call being typed: a signature naming this parameter. signature: (Typed, &'static str), /// The Structure view of a file: these symbols, at any depth. @@ -104,8 +103,8 @@ enum Imports { struct QuickFix { typed: Typed, diagnostic: &'static str, - title: &'static str, - result: &'static str, + /// The quick fix with this title makes the file contain this text. + fix: Option<(&'static str, &'static str)>, } /// The editor's indentation, sent with a format request. @@ -135,7 +134,7 @@ fn copy_dir(from: &Path, to: &Path) { for entry in std::fs::read_dir(from).unwrap() { let entry = entry.unwrap(); let name = entry.file_name(); - if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) { + if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv" | "vendor")) { continue; } let target = to.join(&name); @@ -392,19 +391,21 @@ impl Smoke { .unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}"))) } - async fn quick_fix(&self, fix: QuickFix) { - let (doc, _, before) = self.type_line(fix.typed).await; - assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result"); - let range = self.diagnostic(fix.typed.file, fix.diagnostic).await; - let action = self.action("quick fix", doc, range, fix.title).await; - ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![]) - .await - .unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title))); - self.retry("quick fix", Duration::from_secs(10), || async { - let text = self.doc(fix.typed.file).await.1; - if text.contains(fix.result) { Ok(()) } else { Err(text) } - }) - .await; + async fn quick_fix(&self, q: QuickFix) { + let (doc, _, before) = self.type_line(q.typed).await; + let range = self.diagnostic(q.typed.file, q.diagnostic).await; + if let Some((title, result)) = q.fix { + assert!(!before.contains(result), "{q:?}: the file already contains the result"); + let action = self.action("quick fix", doc, range, title).await; + ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![]) + .await + .unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {title:?}: {e}"))); + self.retry("quick fix", Duration::from_secs(10), || async { + let text = self.doc(q.typed.file).await.1; + if text.contains(result) { Ok(()) } else { Err(text) } + }) + .await; + } self.restore(doc, &before).await; } @@ -619,7 +620,9 @@ impl Smoke { self.hover(c.hover.0, c.hover.1).await; self.completion(c.completion.0, c.completion.1).await; self.quick_fix(c.quick_fix).await; - self.intention(c.intention.0, c.intention.1).await; + if let Some((on, title)) = c.intention { + self.intention(on, title).await; + } self.format(c.format.0, c.indent, c.format.1).await; self.signature(c.signature.0, c.signature.1).await; self.outline(c.outline.0, c.outline.1).await; @@ -627,7 +630,9 @@ impl Smoke { if let Some((import, expect)) = c.organize_imports { self.organize_imports(import, expect).await; } - self.rename(c.rename.0, c.rename.1, c.rename.2).await; + if let Some((symbol, to, files)) = c.rename { + self.rename(symbol, to, files).await; + } // Last: it moves a file the other checks use. if let Some((from, to, referencing, result)) = c.file_move { self.file_move(from, to, referencing, result).await; @@ -656,21 +661,20 @@ const TYPESCRIPT: Checks = Checks { quick_fix: QuickFix { typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"), diagnostic: "Cannot find name 'addMoney'", - title: "import from \"./money.ts\"", - result: "import { addMoney, cents, formatMoney } from \"./money.ts\";", + fix: Some(("import from \"./money.ts\"", "import { addMoney, cents, formatMoney } from \"./money.ts\";")), }, - intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"), + intention: Some((at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant")), format: ( typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"), " const shown = { total, count: cart.count }", ), indent: Indent::Spaces(2), - rename: ( + rename: Some(( // From a usage in another file: the declaration is renamed too, not aliased in the import. at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"), "formatPrice", &["src/index.ts", "src/money.ts"], - ), + )), signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"), outline: ("src/cart.ts", &["Cart", "add", "total"]), auto_import: AutoImport { @@ -713,21 +717,20 @@ const PYTHON: Checks = Checks { quick_fix: QuickFix { typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"), diagnostic: "\"Task\" is not defined", - title: "from .models import Task", - result: "from .models import Priority, Task, TaskList", + fix: Some(("from .models import Task", "from .models import Priority, Task, TaskList")), }, // basedpyright has no refactorings; Ruff's fixes are the Python intentions. - intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"), + intention: Some((at("src/tasks/models.py", "import os", "os"), "Remove unused import")), format: ( typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"), " shown2 = {\"a\": 1, \"b\": 2}", ), indent: Indent::Spaces(4), - rename: ( + rename: Some(( at("src/tasks/models.py", "class TaskList:", "TaskList"), "TaskBoard", &["src/tasks/cli.py", "src/tasks/models.py"], - ), + )), signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"), outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]), auto_import: AutoImport { @@ -768,16 +771,22 @@ const RUST: Checks = Checks { " let _blank = parser::ParseError { line: 1 };", ), diagnostic: "missing structure fields", - title: "Fill struct fields", - result: "ParseError { line: 1, message", + fix: Some(("Fill struct fields", "ParseError { line: 1, message")), }, - intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"), + intention: Some(( + at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), + "Extract into variable", + )), format: ( typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"), " let _spaced = (1, 2);", ), indent: Indent::Spaces(4), - rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]), + rename: Some(( + at("src/parser.rs", "pub fn parse(text: &str)", "parse"), + "parse_all", + &["src/main.rs", "src/parser.rs"], + )), signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"), outline: ("src/lib.rs", &["Inventory", "Item", "take"]), auto_import: AutoImport { @@ -816,17 +825,16 @@ const GO: Checks = Checks { quick_fix: QuickFix { typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"), diagnostic: "undefined: fmt", - title: "\"fmt\"", - result: "\t\"fmt\"", + fix: Some(("\"fmt\"", "\t\"fmt\"")), }, - intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"), + intention: Some((at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract")), format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"), indent: Indent::Tabs, - rename: ( + rename: Some(( at("internal/store/store.go", "func New() *Store", "New"), "Open", &["cmd/server/main.go", "internal/store/store.go"], - ), + )), signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"), outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]), auto_import: AutoImport { @@ -872,13 +880,12 @@ const JAVA: Checks = Checks { " List all = book.byCustomer(\"alice\");", ), diagnostic: "List cannot be resolved to a type", - title: "Import 'List' (java.util)", - result: "import java.util.List;", + fix: Some(("Import 'List' (java.util)", "import java.util.List;")), }, - intention: ( + intention: Some(( at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"), "Extract to local variable", - ), + )), format: ( typed( "src/main/java/com/example/orders/App.java", @@ -888,11 +895,11 @@ const JAVA: Checks = Checks { " long doubled = revenue * 2;", ), indent: Indent::Spaces(4), - rename: ( + rename: Some(( at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"), "submit", &["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"], - ), + )), signature: ( typed( "src/main/java/com/example/orders/App.java", @@ -959,23 +966,22 @@ const KOTLIN: Checks = Checks { " val stamp = LocalDateTime.now()", ), diagnostic: "Unresolved reference", - title: "Import", - result: "import java.time.LocalDateTime", + fix: Some(("Import", "import java.time.LocalDateTime")), }, - intention: ( + intention: Some(( at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"), "block body", - ), + )), format: ( typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"), " val pair = listOf(1, 2)", ), indent: Indent::Spaces(4), - rename: ( + rename: Some(( at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"), "find", &["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"], - ), + )), signature: ( typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("), "isbn", @@ -1003,3 +1009,51 @@ const KOTLIN: Checks = Checks { async fn kotlin() { smoke("kotlin", KOTLIN).await; } + +const PHP: Checks = Checks { + fixture: "php-app", + files: &[], + warm_up: Duration::from_secs(60), + definition: ( + at("bin/quote.php", "$customs = new ThresholdCustoms", "ThresholdCustoms"), + at("src/Shipping/ThresholdCustoms.php", "final class ThresholdCustoms", "ThresholdCustoms"), + ), + references: ( + at("src/Shipping/Parcel.php", "public function billableGrams", "billableGrams"), + &["bin/quote.php", "src/Shipping/Parcel.php", "src/Shipping/Shipment.php", "src/Shipping/Tariff.php"], + ), + hover: ( + at("bin/quote.php", "$parcel->billableGrams($shipment->route)", "billableGrams"), + "public function billableGrams(Route $route): int", + ), + completion: (typed("bin/quote.php", "$freightCents += $cents;", " $parcel->"), "billableGrams"), + // Intelephense declares no code actions and no rename without a licence key. + quick_fix: QuickFix { + typed: typed("bin/quote.php", "$freightCents += $cents;", " $tariff->weigh($parcel);"), + diagnostic: "Undefined method 'weigh'", + fix: None, + }, + intention: None, + format: ( + typed("bin/quote.php", "$freightCents += $cents;", " $pair=[1,2 ,3];"), + " $pair = [1, 2, 3];", + ), + indent: Indent::Spaces(4), + rename: None, + signature: (typed("bin/quote.php", "$freightCents += $cents;", " $tariff->freightCents("), "parcel"), + outline: ("src/Shipping/Shipment.php", &["Shipment", "add", "heaviest", "byStore"]), + auto_import: AutoImport { + typed: typed("bin/quote.php", "$freightCents += $cents;", " $status = ParcelSta"), + label: "ParcelStatus", + result: "use App\\Shipping\\ParcelStatus;", + }, + organize_imports: None, + // Intelephense has no `workspace/willRenameFiles`. + file_move: None, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "language smoke: needs intelephense"] +async fn php() { + smoke("php", PHP).await; +} diff --git a/fixtures/README.md b/fixtures/README.md index a4353ca3f..9e94a209c 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -13,6 +13,7 @@ something to show. | `go-app/` | Go 1.22+ | `go run ./cmd/server` | `go test ./...` | `OPENIDEA_FAIL=1 go test ./...` | unused `fmt.Sprintf` result in `internal/store/store.go` | | `kotlin-gradle/` | JDK 21, Gradle 8 (no wrapper) | `gradle run` | `gradle test` | `gradle test -PopenideaFail` | unused variable in `App.kt` | | `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` | +| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` | | `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` | The fixtures are standalone: `rust-app` declares its own `[workspace]` and @@ -36,7 +37,8 @@ git init -q && git add -A && git commit -qm "initial" # for the VCS checks Then open the folder with **File › Open…** (`⌘⇧⌥O`) or from the Welcome screen. For `ts-app` / `polyglot/web` run `pnpm install` first; for `python-app` create a -venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`). +venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`); for +`php-app` run `composer install` (debugging it needs Xdebug in the PHP on `PATH`). Missing language servers / debuggers can be installed from the IDE: **Settings › Languages & Frameworks › Language Servers**, or from the @@ -57,17 +59,18 @@ language detection and highlighting. ### Project tree - [ ] Arrow keys move the selection; `→`/`←` expand/collapse; `Enter`/`F4` opens a file. -- [ ] Ignored folders (`target/`, `node_modules/`, `build/`) appear dimmed. +- [ ] Ignored folders (`target/`, `node_modules/`, `build/`, `vendor/`) appear dimmed. - [ ] New file (`⌘N`), rename (`⇧F6`), delete (`⌫`) work and the tree updates. ### Search Everywhere & navigation - [ ] Double `⇧` opens Search Everywhere; typing `parser` finds `src/parser.rs`. -- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library`. +- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library` / + `Shipment`. - [ ] `⌘⇧O` (files) and `⌘⌥O` (symbols) find `format_price` / `formatMoney`. - [ ] `⌘E` lists recent files; `⌘[` / `⌘]` navigate back/forward. -- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`) - jumps to the declaration; `⌥F7` lists usages. +- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`, + `billableGrams` in `quote.php`) jumps to the declaration; `⌥F7` lists usages. - [ ] `⌘F12` shows the file structure; `⌘L` goes to a line. ### Editing & code insight @@ -75,7 +78,8 @@ language detection and highlighting. - [ ] Typing in a file marks the tab modified; `⌘S` saves; external edits reload. - [ ] `⌘F` opens the find bar (match case / words / regex); `⌘R` replaces. - [ ] `⌘⇧F` finds `TODO` across the project and opens results. -- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` lists members. +- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` / `$parcel->` lists + members. - [ ] Hover / `F1` shows documentation; `⌘P` shows parameter info. - [ ] Warnings: the intentional warning is underlined and listed in Problems (`⌘6`); `F2` jumps to it; `⌥Enter` offers a quick fix. @@ -91,7 +95,8 @@ language detection and highlighting. ### Run configurations & tests - [ ] Run configurations are detected: cargo run/test (Rust), package.json scripts - (TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin). + (TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin), + composer.json scripts and Pest (PHP). - [ ] Gutter run icons appear next to `main` and test functions; running one opens the Run tool window (`⌘4`) with the output. - [ ] The test tree shows passed tests; enabling the failing test (table above) @@ -103,10 +108,10 @@ language detection and highlighting. - [ ] Each fixture's entry point has a "Set a breakpoint here" comment: toggle a breakpoint there (`⌘F8`) and start debugging (`⌃D`). - [ ] Execution stops on the line; Frames and Variables show `inventory` / `cart` / - `tasks` / `library`; inline values appear in the editor. + `tasks` / `library` / `$shipment`; inline values appear in the editor. - [ ] Step over (`F8`), into (`F7`), out (`⇧F8`), resume (`⌘⌥R`) and evaluate (`⌥F8`). -- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve), the error offers - the installer and debugging works after installing. +- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve, PHP Debug), the error + offers the installer and debugging works after installing. ### Git diff --git a/fixtures/php-app/.gitignore b/fixtures/php-app/.gitignore new file mode 100644 index 000000000..e8c60e98e --- /dev/null +++ b/fixtures/php-app/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache diff --git a/fixtures/php-app/bin/quote.php b/fixtures/php-app/bin/quote.php new file mode 100644 index 000000000..05b80f46d --- /dev/null +++ b/fixtures/php-app/bin/quote.php @@ -0,0 +1,46 @@ + (fn (float $dollars): string => number_format($dollars, 2)) + |> (fn (string $amount): string => '$'.$amount); +} + +$tariff = new Tariff(['air' => 1000, 'ground' => 500]); +$customs = new ThresholdCustoms; +$tracker = new Uri('https://track.example.com'); +$shipment = new Shipment('FL-0412', Route::Air) + ->add(new Parcel('PCL-0001', 'Northwind Books', 1200, new Dimensions(30, 20, 10), 4500)) + ->add(new Parcel('PCL-0002', 'Bluebird Electronics', 2400, new Dimensions(40, 30, 20), 6000)) + ->add(new Parcel('PCL-0003', 'Bluebird Electronics', 800, new Dimensions(20, 15, 10), 5500)); + +foreach ($shipment->byStore() as $store => $parcels) { + $freightCents = 0; + foreach ($parcels as $parcel) { + $cents = $tariff->freightCents($parcel, $shipment->route); + $freightCents += $cents; + printf("%s %.2f kg billable freight %s\n", $parcel->code, $parcel->billableGrams($shipment->route) / 1000, money($cents)); + } + + $charges = $customs->assess($parcels, $freightCents); + $summary = $charges->isDutyFree() ? 'duty-free' : sprintf('VAT %s, clearance fee %s', money($charges->vatCents), money($charges->feeCents)); + echo "$store: $summary\n"; +} + +$heaviest = $shipment->heaviest(); // Set a breakpoint here +if ($heaviest !== null) { + echo "Heaviest: $heaviest->code, tracking {$heaviest->trackingUrl($tracker)->toString()}\n"; +} diff --git a/fixtures/php-app/composer.json b/fixtures/php-app/composer.json new file mode 100644 index 000000000..c222516a7 --- /dev/null +++ b/fixtures/php-app/composer.json @@ -0,0 +1,34 @@ +{ + "name": "openidea/php-app", + "description": "OpenIDEA QA fixture: shipping quotes and customs charges for forwarded parcels.", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.5" + }, + "require-dev": { + "laravel/pint": "^1.29", + "pestphp/pest": "^5.0", + "phpstan/phpstan": "^2.1" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "quote": "php bin/quote.php", + "lint": "pint --test", + "analyse": "phpstan analyse" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/fixtures/php-app/phpstan.neon b/fixtures/php-app/phpstan.neon new file mode 100644 index 000000000..38fdb04c3 --- /dev/null +++ b/fixtures/php-app/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 9 + paths: + - src + - bin diff --git a/fixtures/php-app/phpunit.xml b/fixtures/php-app/phpunit.xml new file mode 100644 index 000000000..1e8ee00bf --- /dev/null +++ b/fixtures/php-app/phpunit.xml @@ -0,0 +1,16 @@ + + + + + tests + + + + + src + + + diff --git a/fixtures/php-app/pint.json b/fixtures/php-app/pint.json new file mode 100644 index 000000000..2f9795539 --- /dev/null +++ b/fixtures/php-app/pint.json @@ -0,0 +1,19 @@ +{ + "preset": "laravel", + "rules": { + "cast_spaces": { + "space": "single" + }, + "phpdoc_line_span": { + "case": "single", + "class": "single", + "const": "single", + "function": "single", + "method": "single", + "other": "single", + "property": "single", + "trait_import": "single" + }, + "not_operator_with_successor_space": false + } +} diff --git a/fixtures/php-app/src/Shipping/Charges.php b/fixtures/php-app/src/Shipping/Charges.php new file mode 100644 index 000000000..c7d6ff3ff --- /dev/null +++ b/fixtures/php-app/src/Shipping/Charges.php @@ -0,0 +1,23 @@ +vatCents === 0 && $this->feeCents === 0; + } +} diff --git a/fixtures/php-app/src/Shipping/CustomsPolicy.php b/fixtures/php-app/src/Shipping/CustomsPolicy.php new file mode 100644 index 000000000..6c82ad0c7 --- /dev/null +++ b/fixtures/php-app/src/Shipping/CustomsPolicy.php @@ -0,0 +1,13 @@ + $parcels */ + public function assess(array $parcels, int $freightCents): Charges; +} diff --git a/fixtures/php-app/src/Shipping/Dimensions.php b/fixtures/php-app/src/Shipping/Dimensions.php new file mode 100644 index 000000000..6ca5db9c9 --- /dev/null +++ b/fixtures/php-app/src/Shipping/Dimensions.php @@ -0,0 +1,18 @@ + intdiv($this->lengthCm * $this->widthCm * $this->heightCm, 5); + } + + public function __construct( + public readonly int $lengthCm, + public readonly int $widthCm, + public readonly int $heightCm, + ) {} +} diff --git a/fixtures/php-app/src/Shipping/InvalidStatusTransition.php b/fixtures/php-app/src/Shipping/InvalidStatusTransition.php new file mode 100644 index 000000000..bfc7222a3 --- /dev/null +++ b/fixtures/php-app/src/Shipping/InvalidStatusTransition.php @@ -0,0 +1,15 @@ +code, $parcel->status->value, $next->value)); + } +} diff --git a/fixtures/php-app/src/Shipping/Parcel.php b/fixtures/php-app/src/Shipping/Parcel.php new file mode 100644 index 000000000..f97a3b374 --- /dev/null +++ b/fixtures/php-app/src/Shipping/Parcel.php @@ -0,0 +1,40 @@ +weightGrams, $this->dimensions->volumetricGrams, $route->minimumGrams()); + } + + #[NoDiscard('a parcel is immutable, use the returned one')] + public function moveTo(ParcelStatus $next): self + { + if (!$this->status->canMoveTo($next)) { + throw InvalidStatusTransition::between($this, $next); + } + + return clone ($this, ['status' => $next]); + } + + public function trackingUrl(Uri $tracker): Uri + { + return $tracker->withPath("/parcels/$this->code"); + } +} diff --git a/fixtures/php-app/src/Shipping/ParcelStatus.php b/fixtures/php-app/src/Shipping/ParcelStatus.php new file mode 100644 index 000000000..97aa39f87 --- /dev/null +++ b/fixtures/php-app/src/Shipping/ParcelStatus.php @@ -0,0 +1,33 @@ + $next === self::Received, + self::Received => $next === self::AwaitingContents, + self::AwaitingContents => $next === self::ContentsConfirmed, + self::ContentsConfirmed => $next === self::InTransit, + self::InTransit => $next === self::Arrived, + self::Arrived => in_array($next, [self::Ready, self::Held], true), + self::Held => $next === self::Ready, + self::Ready => $next === self::Delivered, + self::Delivered => false, + }; + } +} diff --git a/fixtures/php-app/src/Shipping/Route.php b/fixtures/php-app/src/Shipping/Route.php new file mode 100644 index 000000000..5ea8b0fd7 --- /dev/null +++ b/fixtures/php-app/src/Shipping/Route.php @@ -0,0 +1,24 @@ + 100, + default => 1000, + }; + } +} diff --git a/fixtures/php-app/src/Shipping/Shipment.php b/fixtures/php-app/src/Shipping/Shipment.php new file mode 100644 index 000000000..1cb182017 --- /dev/null +++ b/fixtures/php-app/src/Shipping/Shipment.php @@ -0,0 +1,73 @@ + */ +final class Shipment implements Countable, IteratorAggregate +{ + /** @var list */ + public private(set) array $parcels = []; + + public function __construct( + public readonly string $flight, + public readonly Route $route, + ) {} + + public function add(Parcel $parcel): self + { + $this->parcels[] = $parcel; + + return $this; + } + + public function heaviest(): ?Parcel + { + return $this->parcels + |> (fn (array $parcels): array => $this->sortedByBillableWeight($parcels)) + |> array_last(...); + } + + public function isReadyForDeparture(): bool + { + return array_all($this->parcels, fn (Parcel $parcel): bool => $parcel->status === ParcelStatus::ContentsConfirmed); + } + + /** @return array> */ + public function byStore(): array + { + $groups = []; + foreach ($this->parcels as $parcel) { + $groups[$parcel->store][] = $parcel; + } + + return $groups; + } + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->parcels); + } + + public function count(): int + { + return count($this->parcels); + } + + /** + * @param list $parcels + * @return list + */ + private function sortedByBillableWeight(array $parcels): array + { + usort($parcels, fn (Parcel $a, Parcel $b): int => $a->billableGrams($this->route) <=> $b->billableGrams($this->route)); + + return $parcels; + } +} diff --git a/fixtures/php-app/src/Shipping/Tariff.php b/fixtures/php-app/src/Shipping/Tariff.php new file mode 100644 index 000000000..6c8bbe0d8 --- /dev/null +++ b/fixtures/php-app/src/Shipping/Tariff.php @@ -0,0 +1,18 @@ + $centsPerKg */ + public function __construct(private array $centsPerKg) {} + + public function freightCents(Parcel $parcel, Route $route): int + { + $grams = $parcel->billableGrams($route); + + return intdiv($grams * $this->centsPerKg[$route->value] + 999, 1000); + } +} diff --git a/fixtures/php-app/src/Shipping/ThresholdCustoms.php b/fixtures/php-app/src/Shipping/ThresholdCustoms.php new file mode 100644 index 000000000..c1e0477dc --- /dev/null +++ b/fixtures/php-app/src/Shipping/ThresholdCustoms.php @@ -0,0 +1,64 @@ +valueCents; + }; + + private const Closure WeightGrams = static function (Parcel $parcel): int { + return $parcel->weightGrams; + }; + + #[Override] + public string $name = 'threshold'; + + #[Override] + public function assess(array $parcels, int $freightCents): Charges + { + $valueCents = $parcels |> (fn (array $all): array => array_map(self::ValueCents, $all)) |> array_sum(...); + $grams = $parcels |> (fn (array $all): array => array_map(self::WeightGrams, $all)) |> array_sum(...); + $parcelCount = count($parcels); + + if (!$this->exceedsDutyFree($valueCents, $grams)) { + return Charges::dutyFree(); + } + + // TODO: add the duty by goods code + $vatCents = intdiv(($valueCents + $freightCents) * self::VatPercent, 100); + + return new Charges($vatCents, $this->clearanceFee($valueCents)); + } + + private function exceedsDutyFree(int $valueCents, int $grams): bool + { + if ($valueCents > self::DutyFreeCents) { + return true; + } + + return $grams > self::DutyFreeGrams; + } + + private function clearanceFee(int $valueCents): int + { + // FIXME: read the tiers from configuration + return match (true) { + $valueCents <= 1_000_00 => 5_00, + $valueCents <= 5_000_00 => 25_00, + default => 75_00, + }; + } +} diff --git a/fixtures/php-app/tests/ArchTest.php b/fixtures/php-app/tests/ArchTest.php new file mode 100644 index 000000000..6d801f1f6 --- /dev/null +++ b/fixtures/php-app/tests/ArchTest.php @@ -0,0 +1,22 @@ +expect([Charges::class, Parcel::class, Tariff::class]) + ->toBeFinal() + ->toBeReadonly(); + +arch('customs policies implement the contract') + ->expect(ThresholdCustoms::class) + ->toImplement(CustomsPolicy::class); + +arch('application code does not contain debug helpers') + ->expect('App') + ->not->toUse(['dd', 'dump', 'var_dump']); diff --git a/fixtures/php-app/tests/Shipping/ParcelTest.php b/fixtures/php-app/tests/Shipping/ParcelTest.php new file mode 100644 index 000000000..958c8f348 --- /dev/null +++ b/fixtures/php-app/tests/Shipping/ParcelTest.php @@ -0,0 +1,42 @@ +billableGrams(Route::Air))->toBe(4800); +}); + +it('bills the route minimum for a small parcel', function (): void { + $parcel = ParcelFactory::make(weightGrams: 300, dimensions: new Dimensions(10, 10, 2)); + + expect($parcel->billableGrams(Route::Ground))->toBe(1000); +}); + +it('moves to the next status in a copy', function (): void { + $expected = ParcelFactory::make(); + + $received = $expected->moveTo(ParcelStatus::Received); + + expect($received->status)->toBe(ParcelStatus::Received) + ->and($expected->status)->toBe(ParcelStatus::Expected) + ->and($received->code)->toBe($expected->code); +}); + +it('rejects a skipped status', function (): void { + (void) ParcelFactory::make()->moveTo(ParcelStatus::Received)->moveTo(ParcelStatus::InTransit); +})->throws(InvalidStatusTransition::class); + +it('links to its tracking page', function (): void { + $url = ParcelFactory::make()->trackingUrl(new Uri('https://track.example.com')); + + expect($url->toString())->toBe('https://track.example.com/parcels/PCL-0009'); +}); diff --git a/fixtures/php-app/tests/Shipping/ShipmentTest.php b/fixtures/php-app/tests/Shipping/ShipmentTest.php new file mode 100644 index 000000000..4afc5cd47 --- /dev/null +++ b/fixtures/php-app/tests/Shipping/ShipmentTest.php @@ -0,0 +1,36 @@ +add($dense)->add($bulky); + + expect($shipment->heaviest())->toBe($bulky); +}); + +it('departs once every parcel has confirmed contents', function (): void { + $confirmed = ParcelFactory::make() + ->moveTo(ParcelStatus::Received) + ->moveTo(ParcelStatus::AwaitingContents) + ->moveTo(ParcelStatus::ContentsConfirmed); + + $shipment = new Shipment('FL-0412', Route::Air)->add($confirmed); + + expect($shipment->isReadyForDeparture())->toBeTrue() + ->and($shipment->add(ParcelFactory::make())->isReadyForDeparture())->toBeFalse(); +}); + +it('keeps its parcel list read-only from outside', function (): void { + $shipment = new Shipment('FL-0412', Route::Air); + + expect(fn () => $shipment->parcels = [])->toThrow(Error::class); +}); diff --git a/fixtures/php-app/tests/Shipping/ThresholdCustomsTest.php b/fixtures/php-app/tests/Shipping/ThresholdCustomsTest.php new file mode 100644 index 000000000..ebf9a3bce --- /dev/null +++ b/fixtures/php-app/tests/Shipping/ThresholdCustomsTest.php @@ -0,0 +1,23 @@ +assess([ParcelFactory::make(valueCents: 4500)], 1200); + + expect($charges->isDutyFree())->toBeTrue(); +}); + +it('sums the parcels of one store', function (): void { + $charges = new ThresholdCustoms()->assess([ParcelFactory::make(valueCents: 6000), ParcelFactory::make(valueCents: 5500)], 2000); + + expect($charges->vatCents)->toBe(2700) + ->and($charges->feeCents)->toBe(500); +}); + +it('intentionally fails (set OPENIDEA_FAIL=1)', function (): void { + expect(1)->toBe(2); +})->skip(getenv('OPENIDEA_FAIL') !== '1', 'intentionally fails (set OPENIDEA_FAIL=1)'); diff --git a/fixtures/php-app/tests/TestSupport/ParcelFactory.php b/fixtures/php-app/tests/TestSupport/ParcelFactory.php new file mode 100644 index 000000000..61e27b6f4 --- /dev/null +++ b/fixtures/php-app/tests/TestSupport/ParcelFactory.php @@ -0,0 +1,16 @@ + [...text.matchAll(/(?<=@)import[^;]+/g)].join("\n"), diff --git a/smoke/debug.test.ts b/smoke/debug.test.ts index 632304c4f..181fa8368 100644 --- a/smoke/debug.test.ts +++ b/smoke/debug.test.ts @@ -89,6 +89,15 @@ const CASES: Record = { variable: "library", evaluate: ["library", /Library/], }, + php: { + fixture: "php-app", + setup: (root) => + execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }), + debugger: "php-debug", + file: "bin/quote.php", + variable: "$shipment", + evaluate: ["count($shipment)", /^3$/], + }, }; let deactivate: (() => void) | null = null; diff --git a/smoke/run.test.ts b/smoke/run.test.ts index 55a1945b0..bcea177a9 100644 --- a/smoke/run.test.ts +++ b/smoke/run.test.ts @@ -122,6 +122,19 @@ const CASES: Record = { file: "LibraryTest.kt", }, }, + php: { + fixture: "php-app", + setup: (root) => + execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }), + run: { config: "composer:quote", prints: "Bluebird Electronics: VAT $34.20, clearance fee $5.00" }, + tests: { + config: "phpunit", + failing: (c) => env(c, { OPENIDEA_FAIL: "1" }), + passed: 13, + failed: "it intentionally fails (set OPENIDEA_FAIL=1)", + file: "ThresholdCustomsTest.php", + }, + }, }; let server: Server | null = null;