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>
This commit is contained in:
lleviathe
2026-11-10 12:10:51 +08:00
committed by GitHub
parent 00573f448f
commit f50d3099ce
27 changed files with 740 additions and 66 deletions
+9 -1
View File
@@ -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'
+107 -53
View File
@@ -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<Order> 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;
}
+15 -10
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
/vendor/
/composer.lock
/.phpunit.cache/
/.phpunit.result.cache
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\Parcel;
use App\Shipping\Route;
use App\Shipping\Shipment;
use App\Shipping\Tariff;
use App\Shipping\ThresholdCustoms;
use Uri\Rfc3986\Uri;
require __DIR__.'/../vendor/autoload.php';
function money(int $cents): string
{
return $cents / 100
|> (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";
}
+34
View File
@@ -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
}
}
}
+5
View File
@@ -0,0 +1,5 @@
parameters:
level: 9
paths:
- src
- bin
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
+19
View File
@@ -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
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final readonly class Charges
{
public function __construct(
public int $vatCents,
public int $feeCents,
) {}
public static function dutyFree(): self
{
return new self(0, 0);
}
public function isDutyFree(): bool
{
return $this->vatCents === 0 && $this->feeCents === 0;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
interface CustomsPolicy
{
public string $name { get; }
/** @param list<Parcel> $parcels */
public function assess(array $parcels, int $freightCents): Charges;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final class Dimensions
{
public int $volumetricGrams {
get => intdiv($this->lengthCm * $this->widthCm * $this->heightCm, 5);
}
public function __construct(
public readonly int $lengthCm,
public readonly int $widthCm,
public readonly int $heightCm,
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use DomainException;
final class InvalidStatusTransition extends DomainException
{
public static function between(Parcel $parcel, ParcelStatus $next): self
{
return new self(sprintf("%s can't move from %s to %s", $parcel->code, $parcel->status->value, $next->value));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use NoDiscard;
use Uri\Rfc3986\Uri;
final readonly class Parcel
{
public function __construct(
public string $code,
public string $store,
public int $weightGrams,
public Dimensions $dimensions,
public int $valueCents,
public ParcelStatus $status = ParcelStatus::Expected,
) {}
public function billableGrams(Route $route): int
{
return max($this->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");
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
enum ParcelStatus: string
{
case Expected = 'expected';
case Received = 'received';
case AwaitingContents = 'awaiting_contents';
case ContentsConfirmed = 'contents_confirmed';
case InTransit = 'in_transit';
case Arrived = 'arrived';
case Held = 'held';
case Ready = 'ready';
case Delivered = 'delivered';
public function canMoveTo(self $next): bool
{
return match ($this) {
self::Expected => $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,
};
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use Deprecated;
enum Route: string
{
case Air = 'air';
case Ground = 'ground';
#[Deprecated('sea freight is no longer offered', since: '2026-01')]
case Sea = 'sea';
public function minimumGrams(): int
{
return match ($this) {
self::Air => 100,
default => 1000,
};
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use ArrayIterator;
use Countable;
use IteratorAggregate;
/** @implements IteratorAggregate<int, Parcel> */
final class Shipment implements Countable, IteratorAggregate
{
/** @var list<Parcel> */
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<string, list<Parcel>> */
public function byStore(): array
{
$groups = [];
foreach ($this->parcels as $parcel) {
$groups[$parcel->store][] = $parcel;
}
return $groups;
}
/** @return ArrayIterator<int, Parcel> */
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->parcels);
}
public function count(): int
{
return count($this->parcels);
}
/**
* @param list<Parcel> $parcels
* @return list<Parcel>
*/
private function sortedByBillableWeight(array $parcels): array
{
usort($parcels, fn (Parcel $a, Parcel $b): int => $a->billableGrams($this->route) <=> $b->billableGrams($this->route));
return $parcels;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final readonly class Tariff
{
/** @param array<string, int> $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);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use Closure;
use Override;
final class ThresholdCustoms implements CustomsPolicy
{
private const int DutyFreeCents = 100_00;
private const int DutyFreeGrams = 20_000;
private const int VatPercent = 20;
private const Closure ValueCents = static function (Parcel $parcel): int {
return $parcel->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,
};
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Shipping\Charges;
use App\Shipping\CustomsPolicy;
use App\Shipping\Parcel;
use App\Shipping\Tariff;
use App\Shipping\ThresholdCustoms;
arch('value objects are final and readonly')
->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']);
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\InvalidStatusTransition;
use App\Shipping\ParcelStatus;
use App\Shipping\Route;
use Tests\TestSupport\ParcelFactory;
use Uri\Rfc3986\Uri;
it('bills the volumetric weight of a light bulky parcel', function (): void {
$parcel = ParcelFactory::make(weightGrams: 2400, dimensions: new Dimensions(40, 30, 20));
expect($parcel->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');
});
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\ParcelStatus;
use App\Shipping\Route;
use App\Shipping\Shipment;
use Tests\TestSupport\ParcelFactory;
it('finds the heaviest parcel by billable weight', function (): void {
$bulky = ParcelFactory::make(weightGrams: 900, dimensions: new Dimensions(40, 30, 20));
$dense = ParcelFactory::make(weightGrams: 3000, dimensions: new Dimensions(10, 10, 10));
$shipment = new Shipment('FL-0412', Route::Air)->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);
});
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Shipping\ThresholdCustoms;
use Tests\TestSupport\ParcelFactory;
it('clears a small purchase duty-free', function (): void {
$charges = new ThresholdCustoms()->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)');
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Tests\TestSupport;
use App\Shipping\Dimensions;
use App\Shipping\Parcel;
final class ParcelFactory
{
public static function make(int $weightGrams = 1000, ?Dimensions $dimensions = null, int $valueCents = 5000): Parcel
{
return new Parcel('PCL-0009', 'Bluebird Electronics', $weightGrams, $dimensions ?? new Dimensions(20, 20, 10), $valueCents);
}
}
+3 -2
View File
@@ -17,8 +17,9 @@ const config: KnipConfig = {
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
ignoreExportsUsedInFile: true,
// lint-staged formats Rust files with the toolchain's rustfmt.
ignoreBinaries: ["rustfmt"],
// lint-staged formats Rust files with the toolchain's rustfmt; the PHP smoke tests install
// their fixture with Composer.
ignoreBinaries: ["rustfmt", "composer"],
compilers: {
// Fonts come in through `@import` in global.css.
css: (text: string) => [...text.matchAll(/(?<=@)import[^;]+/g)].join("\n"),
+9
View File
@@ -89,6 +89,15 @@ const CASES: Record<string, Case> = {
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;
+13
View File
@@ -122,6 +122,19 @@ const CASES: Record<string, Case> = {
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;