mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
test(lsp): smoke-test everyday code intelligence per language with real servers (#1299)
* test(lsp): smoke-test everyday code intelligence per language with real servers A new ide-host integration test drives completion, go to definition, Find Usages, hover, diagnostics with a quick fix, an intention, formatting and rename on each language's fixtures/ project, through the ops the app's commands call and the language server the app starts by default: TypeScript (vtsls), Python (basedpyright + Ruff), Rust (rust-analyzer), Go (gopls), Java (jdtls) and Kotlin (kotlin-lsp). A failure names the language and the operation. The tests are ignored by default; a Language smoke workflow installs pinned server versions and runs one language per job on changes to crates/, languages/ and fixtures/. Adds fixtures/java-app (Maven, JDK 21) for Java. * test(lsp): rename TypeScript from a usage and check Rust errors while typing Covers the fixes the smoke test turned up: a TypeScript rename started at a call site in another file renames the declaration (#1301), and rust-analyzer's own diagnostics and fixes arrive without saving (#1306).
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Language smoke (${{ matrix.language }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- if: matrix.language == 'typescript'
|
||||
uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- if: matrix.language == 'python'
|
||||
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
|
||||
|
||||
- if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: "1.27", cache: false }
|
||||
- if: matrix.language == 'go'
|
||||
run: go install "golang.org/x/tools/gopls@$GOPLS"
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/jdtls"
|
||||
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
|
||||
- if: matrix.language == 'kotlin'
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with: { gradle-version: "${{ env.GRADLE }}" }
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
|
||||
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- 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 }}
|
||||
@@ -67,6 +67,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
this worktree's running MCP bridges and actual ports.
|
||||
- `pnpm build` — typecheck + bundle frontend.
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
|
||||
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
//! pinned versions and runs one language per job:
|
||||
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>`.
|
||||
|
||||
use ide_core::{ChangeSet, DocId, IdeEvent, Pos, Range, RequestId, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_lsp::types::{FormatOptions, GotoKind};
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
/// `word` on the first line of `file` that contains `line`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct At {
|
||||
file: &'static str,
|
||||
line: &'static str,
|
||||
word: &'static str,
|
||||
}
|
||||
|
||||
const fn at(file: &'static str, line: &'static str, word: &'static str) -> At {
|
||||
At { file, line, word }
|
||||
}
|
||||
|
||||
/// A line typed after the first line of `file` containing `after`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Typed {
|
||||
file: &'static str,
|
||||
after: &'static str,
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
const fn typed(file: &'static str, after: &'static str, text: &'static str) -> Typed {
|
||||
Typed { file, after, text }
|
||||
}
|
||||
|
||||
/// One language's checklist. Every check names what it looks for, so a failure says which
|
||||
/// operation broke.
|
||||
struct Checks {
|
||||
fixture: &'static str,
|
||||
/// Files written into the project copy before it opens.
|
||||
files: &'static [(&'static str, &'static str)],
|
||||
/// How long the first answer may take (project import and indexing).
|
||||
warm_up: Duration,
|
||||
/// Go to definition from the first spot lands on the second.
|
||||
definition: (At, At),
|
||||
/// Find Usages of the symbol lists usages in these files.
|
||||
references: (At, &'static [&'static str]),
|
||||
/// Hover shows this text.
|
||||
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.
|
||||
quick_fix: QuickFix,
|
||||
/// With the word selected, this intention is offered with an edit or command.
|
||||
intention: (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]),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct QuickFix {
|
||||
typed: Typed,
|
||||
diagnostic: &'static str,
|
||||
title: &'static str,
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// The editor's indentation, sent with a format request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Indent {
|
||||
Spaces(u32),
|
||||
Tabs,
|
||||
}
|
||||
|
||||
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
||||
|
||||
struct Smoke {
|
||||
language: &'static str,
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
events: Events,
|
||||
warm_up: Duration,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn fixture_dir(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(name)
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
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")) {
|
||||
continue;
|
||||
}
|
||||
let target = to.join(&name);
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&entry.path(), &target);
|
||||
} else {
|
||||
std::fs::copy(entry.path(), target).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16_len(s: &str) -> u32 {
|
||||
s.encode_utf16().count() as u32
|
||||
}
|
||||
|
||||
/// The 0-based line index and text of the first line containing `needle`.
|
||||
fn find_line<'a>(text: &'a str, needle: &str, what: &str) -> (u32, &'a str) {
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.find(|(_, l)| l.contains(needle))
|
||||
.map(|(i, l)| (i as u32, l))
|
||||
.unwrap_or_else(|| panic!("{what}: no line containing {needle:?}"))
|
||||
}
|
||||
|
||||
/// The byte offset of `word` in `line`, not as part of a longer identifier (`parse` in `parser::parse`).
|
||||
fn find_word(line: &str, word: &str) -> Option<usize> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
line.match_indices(word).map(|(i, _)| i).find(|&i| {
|
||||
let before = line[..i].chars().next_back();
|
||||
let after = line[i + word.len()..].chars().next();
|
||||
!(word.starts_with(ident) && before.is_some_and(ident)) && !(word.ends_with(ident) && after.is_some_and(ident))
|
||||
})
|
||||
}
|
||||
|
||||
impl Smoke {
|
||||
async fn start(language: &'static str, checks: &Checks) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(checks.fixture);
|
||||
copy_dir(&fixture_dir(checks.fixture), &root);
|
||||
for (path, text) in checks.files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let events: Events = Arc::default();
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new({
|
||||
let events = events.clone();
|
||||
move |_| {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
||||
}
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project(WINDOW, &root).unwrap();
|
||||
Smoke { language, host, root, events, warm_up: checks.warm_up, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn fail(&self, check: &str, message: impl std::fmt::Display) -> ! {
|
||||
let servers = ops::lsp::lsp_servers(&self.host, WINDOW).unwrap_or_default();
|
||||
let servers: Vec<String> = servers.iter().map(|s| format!("{} ({:?})", s.name, s.status)).collect();
|
||||
panic!("{} — {check}: {message}\nservers: {servers:?}", self.language)
|
||||
}
|
||||
|
||||
/// Retries `f` until it succeeds (servers answer emptily while importing and indexing); on
|
||||
/// timeout, fails with its last answer.
|
||||
async fn retry<T, D: std::fmt::Debug, F: Future<Output = Result<T, D>>>(
|
||||
&self,
|
||||
check: &str,
|
||||
timeout: Duration,
|
||||
mut f: impl FnMut() -> F,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let last = match f().await {
|
||||
Ok(v) => return v,
|
||||
Err(last) => last,
|
||||
};
|
||||
if Instant::now() > deadline {
|
||||
self.fail(check, format_args!("timed out after {timeout:?}; last answer: {last:?}"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn doc(&self, file: &str) -> (DocId, String) {
|
||||
let info = ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap();
|
||||
let info = ops::doc::snapshot(&self.host, WINDOW, info.id).await.unwrap();
|
||||
(info.id, info.text)
|
||||
}
|
||||
|
||||
async fn locate(&self, at: At) -> (DocId, Pos) {
|
||||
let (doc, text) = self.doc(at.file).await;
|
||||
let (line, content) = find_line(&text, at.line, at.file);
|
||||
let col = find_word(content, at.word).unwrap_or_else(|| panic!("{at:?}: word not on the line"));
|
||||
(doc, Pos::new(line, utf16_len(&content[..col])))
|
||||
}
|
||||
|
||||
async fn word_range(&self, at: At) -> (DocId, Range) {
|
||||
let (doc, start) = self.locate(at).await;
|
||||
(doc, Range::new(start, Pos::new(start.line, start.col + utf16_len(at.word))))
|
||||
}
|
||||
|
||||
/// Types a new line; returns the position at its end and the text before it.
|
||||
async fn type_line(&self, t: Typed) -> (DocId, Pos, String) {
|
||||
let (doc, text) = self.doc(t.file).await;
|
||||
let (line, content) = find_line(&text, t.after, t.file);
|
||||
let line_start: usize = text.lines().take(line as usize).map(|l| l.len() + 1).sum();
|
||||
let end = utf16_len(&text[..line_start + content.len()]);
|
||||
self.replace(doc, end, end, &format!("\n{}", t.text)).await;
|
||||
let col = utf16_len(t.text);
|
||||
(doc, Pos::new(line + 1, col), text)
|
||||
}
|
||||
|
||||
async fn replace(&self, doc: DocId, from: u32, to: u32, insert: &str) {
|
||||
let version = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().version;
|
||||
let changes = ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] };
|
||||
ops::doc::edit(&self.host, WINDOW, doc, version, changes).unwrap();
|
||||
}
|
||||
|
||||
async fn restore(&self, doc: DocId, text: &str) {
|
||||
let current = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text;
|
||||
self.replace(doc, 0, utf16_len(¤t), text).await;
|
||||
}
|
||||
|
||||
fn path(&self, file: &str) -> PathBuf {
|
||||
self.root.join(file)
|
||||
}
|
||||
|
||||
fn files<P: AsRef<Path>>(&self, paths: impl IntoIterator<Item = P>) -> BTreeSet<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.as_ref().strip_prefix(&self.root).ok()?;
|
||||
Some(p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn definition(&self, from: At, to: At) {
|
||||
let (doc, pos) = self.locate(from).await;
|
||||
let (_, target) = self.locate(to).await;
|
||||
let want = self.path(to.file);
|
||||
self.retry("go to definition", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_goto(&self.host, WINDOW, doc, pos, GotoKind::Definition).await;
|
||||
match &found {
|
||||
Ok(locs) if locs.iter().any(|l| *l.path == *want && l.range.start.line == target.line) => Ok(()),
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn references(&self, of: At, files: &[&str]) {
|
||||
let (doc, pos) = self.locate(of).await;
|
||||
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
||||
self.retry("find usages", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
|
||||
match &found {
|
||||
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
|
||||
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn hover(&self, over: At, shows: &str) {
|
||||
let (doc, pos) = self.locate(over).await;
|
||||
self.retry("hover", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_hover(&self.host, WINDOW, RequestId(2), doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.contents.value.contains(shows) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.contents.value))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn completion(&self, line: Typed, offers: &str) {
|
||||
let (doc, pos, before) = self.type_line(line).await;
|
||||
let trigger = line.text.ends_with('.').then(|| ".".to_string());
|
||||
self.retry("completion", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(3), doc, pos, trigger.clone()).await;
|
||||
match &found {
|
||||
Ok(list)
|
||||
if list.items.iter().any(|i| i.label == offers || i.filter_text.as_deref() == Some(offers)) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(found.map(|l| l.items.into_iter().take(30).map(|i| i.label).collect::<Vec<_>>())),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn diagnostic(&self, file: &str, message: &str) -> Range {
|
||||
let path = self.path(file);
|
||||
self.retry("diagnostics", self.warm_up, || async {
|
||||
let events = self.events.lock().unwrap();
|
||||
let latest = events.iter().rev().filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, source } if **p == *path => Some((source, items)),
|
||||
_ => None,
|
||||
});
|
||||
let mut seen = Vec::new();
|
||||
for (source, items) in latest {
|
||||
if let Some(d) = items.iter().find(|d| d.message.contains(message)) {
|
||||
return Ok(d.range);
|
||||
}
|
||||
seen.push(format!("{source}: {:?}", items.iter().map(|d| &d.message).collect::<Vec<_>>()));
|
||||
}
|
||||
Err(seen.into_iter().take(4).collect::<Vec<_>>())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The code actions over `range` (all kinds, as the ⌥⏎ popup asks), the one titled `title`.
|
||||
async fn action(&self, check: &str, doc: DocId, range: Range, title: &str) -> ide_lsp::types::CodeAction {
|
||||
let action = self
|
||||
.retry(check, self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, range, vec![]).await;
|
||||
match found.as_ref().ok().and_then(|a| a.iter().find(|a| a.title.contains(title))) {
|
||||
Some(action) => Ok(action.clone()),
|
||||
None => Err(found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if action.edit.is_some() || action.command.is_some() {
|
||||
return action;
|
||||
}
|
||||
ops::lsp::lsp_resolve_code_action(&self.host, WINDOW, doc, action)
|
||||
.await
|
||||
.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;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn intention(&self, on: At, title: &str) {
|
||||
let (doc, range) = self.word_range(on).await;
|
||||
let action = self.action("intention", doc, range, title).await;
|
||||
let edits = action.edit.as_ref().is_some_and(|e| !e.files.is_empty());
|
||||
if !edits && action.command.is_none() {
|
||||
self.fail("intention", format_args!("{title:?} has neither an edit nor a command: {action:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn format(&self, line: Typed, indent: Indent, formatted: &str) {
|
||||
let (doc, _, before) = self.type_line(line).await;
|
||||
assert!(!before.lines().any(|l| l == formatted), "{line:?}: the file already has {formatted:?}");
|
||||
let (tab_size, insert_spaces) = match indent {
|
||||
Indent::Spaces(n) => (n, true),
|
||||
Indent::Tabs => (4, false),
|
||||
};
|
||||
let options = FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
|
||||
let text = self
|
||||
.retry("format", self.warm_up, || async {
|
||||
let edits = ops::lsp::lsp_format(&self.host, WINDOW, doc, None, options.clone()).await;
|
||||
let text = self.doc(line.file).await.1;
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
match &edits {
|
||||
Ok(edits) if !edits.is_empty() && document.apply_edits(edits).is_ok() => Ok(document.text()),
|
||||
_ => Err(edits),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if !text.lines().any(|l| l == formatted) {
|
||||
self.fail("format", format_args!("no line {formatted:?} in:\n{text}"));
|
||||
}
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn rename(&self, symbol: At, to: &str, files: &[&str]) {
|
||||
let (doc, pos) = self.locate(symbol).await;
|
||||
let prepared = ops::lsp::lsp_prepare_rename(&self.host, WINDOW, doc, pos).await;
|
||||
if !matches!(prepared, Ok(Some(_))) {
|
||||
self.fail("rename", format_args!("prepare rename: {prepared:?}"));
|
||||
}
|
||||
let renamed = ops::lsp::lsp_rename(&self.host, WINDOW, RequestId(5), doc, pos, to.into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("rename", e));
|
||||
let edited = self.files(renamed.edit.files.iter().filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(path.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
||||
if !want.is_subset(&edited) {
|
||||
self.fail("rename", format_args!("edits {edited:?}, expected at least {want:?}: {:?}", renamed.edit));
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
for file in files {
|
||||
before.push(self.doc(file).await.1.matches(to).count());
|
||||
}
|
||||
ops::lsp::apply_workspace_edit(&self.host, WINDOW, renamed.edit)
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("rename", format_args!("applying the edit: {e}")));
|
||||
for (file, before) in files.iter().zip(before) {
|
||||
if self.doc(file).await.1.matches(to).count() <= before {
|
||||
self.fail("rename", format_args!("{file} has no new {to:?} after the rename"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
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;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(language: &'static str, checks: Checks) {
|
||||
let smoke = Smoke::start(language, &checks).await;
|
||||
smoke.run(&checks).await;
|
||||
}
|
||||
|
||||
const TYPESCRIPT: Checks = Checks {
|
||||
fixture: "ts-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
|
||||
at("src/money.ts", "export function formatMoney", "formatMoney"),
|
||||
),
|
||||
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
|
||||
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
|
||||
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
|
||||
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\";",
|
||||
},
|
||||
intention: (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: (
|
||||
// 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"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs vtsls"]
|
||||
async fn typescript() {
|
||||
smoke("typescript", TYPESCRIPT).await;
|
||||
}
|
||||
|
||||
const PYTHON: Checks = Checks {
|
||||
fixture: "python-app",
|
||||
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
|
||||
files: &[("ruff.toml", "")],
|
||||
warm_up: Duration::from_secs(120),
|
||||
definition: (
|
||||
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
),
|
||||
references: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
|
||||
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
|
||||
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",
|
||||
},
|
||||
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
|
||||
intention: (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: (
|
||||
at("src/tasks/models.py", "class TaskList:", "TaskList"),
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs basedpyright and ruff"]
|
||||
async fn python() {
|
||||
smoke("python", PYTHON).await;
|
||||
}
|
||||
|
||||
const RUST: Checks = Checks {
|
||||
fixture: "rust-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
|
||||
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
|
||||
),
|
||||
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
|
||||
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
|
||||
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
|
||||
quick_fix: QuickFix {
|
||||
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
|
||||
typed: typed(
|
||||
"src/main.rs",
|
||||
"let mut inventory = Inventory::new();",
|
||||
" let _blank = parser::ParseError { line: 1 };",
|
||||
),
|
||||
diagnostic: "missing structure fields",
|
||||
title: "Fill struct fields",
|
||||
result: "ParseError { line: 1, message",
|
||||
},
|
||||
intention: (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"]),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs rust-analyzer"]
|
||||
async fn rust() {
|
||||
smoke("rust", RUST).await;
|
||||
}
|
||||
|
||||
const GO: Checks = Checks {
|
||||
fixture: "go-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(180),
|
||||
definition: (
|
||||
at("cmd/server/main.go", "s := store.New()", "New"),
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
),
|
||||
references: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
hover: (
|
||||
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
|
||||
"func (s *store.Store) Search(q string) []store.Note",
|
||||
),
|
||||
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
|
||||
diagnostic: "undefined: fmt",
|
||||
title: "\"fmt\"",
|
||||
result: "\t\"fmt\"",
|
||||
},
|
||||
intention: (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: (
|
||||
at("internal/store/store.go", "func New() *Store", "New"),
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs gopls"]
|
||||
async fn go() {
|
||||
smoke("go", GO).await;
|
||||
}
|
||||
|
||||
const JAVA: Checks = Checks {
|
||||
fixture: "java-app",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(300),
|
||||
definition: (
|
||||
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
),
|
||||
references: (
|
||||
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
|
||||
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
|
||||
),
|
||||
hover: (
|
||||
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
|
||||
"long com.example.orders.OrderBook.revenueCents()",
|
||||
),
|
||||
completion: (
|
||||
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
|
||||
"largest",
|
||||
),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" List<Order> all = book.byCustomer(\"alice\");",
|
||||
),
|
||||
diagnostic: "List cannot be resolved to a type",
|
||||
title: "Import 'List' (java.util)",
|
||||
result: "import java.util.List;",
|
||||
},
|
||||
intention: (
|
||||
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",
|
||||
"long revenue = book.revenueCents();",
|
||||
" long doubled=revenue*2;",
|
||||
),
|
||||
" long doubled = revenue * 2;",
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (
|
||||
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"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs jdtls and JDK 21"]
|
||||
async fn java() {
|
||||
smoke("java", JAVA).await;
|
||||
}
|
||||
|
||||
const KOTLIN: Checks = Checks {
|
||||
fixture: "kotlin-gradle",
|
||||
files: &[],
|
||||
warm_up: Duration::from_secs(420),
|
||||
definition: (
|
||||
at("src/main/kotlin/com/example/library/App.kt", "val library = Library()", "Library"),
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "class Library(", "Library"),
|
||||
),
|
||||
references: (
|
||||
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
hover: (
|
||||
at("src/main/kotlin/com/example/library/App.kt", "library.search(\"java\")", "search"),
|
||||
"fun search(query: String): List<Book>",
|
||||
),
|
||||
completion: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library."),
|
||||
"lend",
|
||||
),
|
||||
quick_fix: QuickFix {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTime.now()",
|
||||
),
|
||||
diagnostic: "Unresolved reference",
|
||||
title: "Import",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
intention: (
|
||||
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: (
|
||||
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"],
|
||||
),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "language smoke: needs kotlin-lsp, a JDK and Gradle"]
|
||||
async fn kotlin() {
|
||||
smoke("kotlin", KOTLIN).await;
|
||||
}
|
||||
@@ -12,11 +12,16 @@ something to show.
|
||||
| `python-app/` | Python 3.10+, pytest | `python -m tasks.cli` (from `src/`) | `pytest` | `OPENIDEA_FAIL=1 pytest` | unused imports in `models.py` |
|
||||
| `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` |
|
||||
| `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
|
||||
`fixtures/eslint.config.js` keeps them out of the IDE's own lint run.
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers: when you change a fixture, run it for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
Work on a **copy** so git and edits never touch this repository:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>openidea-fixture-orders</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<description>OpenIDEA QA fixture: a tiny order book</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- `mvn test -DopenideaFail=true` runs the intentionally failing test. -->
|
||||
<openideaFail>false</openideaFail>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<openidea.fail>${openideaFail}</openidea.fail>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<configuration>
|
||||
<mainClass>com.example.orders.App</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.orders;
|
||||
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
OrderBook book = new OrderBook();
|
||||
book.place("alice", "Mechanical keyboard", 1, 8999);
|
||||
book.place("bob", "Wireless mouse", 2, 2999);
|
||||
book.place("alice", "USB-C hub", 1, 3999);
|
||||
|
||||
int unused = 42; // intentional warning: unused variable
|
||||
// Set a breakpoint here (QA: debugger) and inspect `book`.
|
||||
long revenue = book.revenueCents();
|
||||
System.out.printf("%d orders, revenue %.2f%n", book.size(), revenue / 100.0);
|
||||
book.largest().ifPresent(o -> System.out.println("Largest: " + o.product()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.orders;
|
||||
|
||||
/** A customer order: a product, how many and the unit price in cents. */
|
||||
public record Order(String customer, String product, int quantity, long unitPriceCents) {
|
||||
|
||||
public Order {
|
||||
if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
|
||||
}
|
||||
|
||||
/** The order's total in cents. */
|
||||
public long totalCents() {
|
||||
return unitPriceCents * quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.orders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Keeps the orders placed so far, in the order they were placed. */
|
||||
public class OrderBook {
|
||||
private final List<Order> orders = new ArrayList<>();
|
||||
|
||||
/** Places an order and returns it. */
|
||||
public Order place(String customer, String product, int quantity, long unitPriceCents) {
|
||||
Order order = new Order(customer, product, quantity, unitPriceCents);
|
||||
orders.add(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
public List<Order> byCustomer(String customer) {
|
||||
return orders.stream().filter(o -> o.customer().equals(customer)).toList();
|
||||
}
|
||||
|
||||
public Optional<Order> largest() {
|
||||
return orders.stream().max(Comparator.comparingLong(Order::totalCents));
|
||||
}
|
||||
|
||||
// TODO: discounts for repeat customers
|
||||
public long revenueCents() {
|
||||
return orders.stream().mapToLong(Order::totalCents).sum();
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return orders.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.orders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
class OrderBookTest {
|
||||
@Test
|
||||
void sumsRevenueOverAllOrders() {
|
||||
OrderBook book = new OrderBook();
|
||||
book.place("alice", "Keyboard", 2, 1000);
|
||||
book.place("bob", "Mouse", 1, 500);
|
||||
assertEquals(2500, book.revenueCents());
|
||||
assertEquals(1, book.byCustomer("bob").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyOrders() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new Order("alice", "Keyboard", 0, 1000));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledIfSystemProperty(named = "openidea.fail", matches = "true")
|
||||
void intentionallyFails() {
|
||||
assertEquals(1, new OrderBook().size(), "QA: an intentionally failing test");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user