mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/vendor/
|
||||
/composer.lock
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- bin
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user