feat(DRW-001) more fixes to ci
CI/CD / Build (push) Successful in 2m12s
CI/CD / Frontend Build (push) Successful in 1m9s
CI/CD / Unit Tests (push) Successful in 3m22s
CI/CD / Frontend Docker (push) Successful in 6m41s
CI/CD / Docker (push) Successful in 10m43s

This commit is contained in:
2026-09-13 21:11:29 +03:00
parent 9f7979f14b
commit cdd481107e
3 changed files with 1 additions and 450 deletions
+1 -21
View File
@@ -42,24 +42,6 @@ jobs:
name: Unit Tests
needs: build
runs-on: ubuntu-latest
# cargo build never touches a database (no compile-time-checked sqlx
# macros are used anywhere), but the tests do -- #[sqlx::test] spins up
# a real, freshly-migrated database per test, which needs an actual
# Postgres server to connect to and CREATE DATABASE against.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -80,9 +62,7 @@ jobs:
restore-keys: rust-${{ runner.os }}-
- name: Run tests
env:
DATABASE_URL: postgres://test:test@localhost:5432/test
run: cargo test --release --locked -- --test-threads=4
run: cargo test --release --locked
docker:
name: Docker
-161
View File
@@ -222,164 +222,3 @@ async fn patch_enchantment(
save_build(&state, build.clone());
Ok(Json(build))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use sqlx::PgPool;
use super::*;
use crate::models::AghanimTiming;
fn test_config() -> BuildConfig {
BuildConfig {
attack_types: vec![],
attributes: vec![],
item_tags: vec![],
disable_neutral_artifact_randomness: false,
disable_enchantment_randomness: false,
disable_all_neutral_rolls: false,
disabled_item_slots: vec![],
disable_aghanim_scepter_randomness: false,
disable_aghanim_shard_randomness: false,
neutral_tier_reroll_enabled: true,
aghanim_scepter_timing: AghanimTiming::Anytime,
aghanim_shard_timing: AghanimTiming::Anytime,
}
}
fn test_state(pool: PgPool) -> AppState {
AppState { pool, builds: Arc::new(Mutex::new(HashMap::new())), neutral_tier_gate_chance: 1.0 }
}
#[sqlx::test]
async fn post_builds_produces_a_full_build(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) =
post_builds(State(state), Json(test_config())).await.expect("build generation should succeed");
assert_eq!(build.item_slots.len(), 6);
assert_eq!(build.neutral_items.len(), 5);
assert_eq!(build.enchantments.len(), 5);
let slot1_name = build.item_slots[0].item.as_ref().unwrap().name.to_lowercase();
assert!(slot1_name.contains("boots") || slot1_name.contains("treads"));
Ok(())
}
#[sqlx::test]
async fn reroll_hero_changes_hero_and_keeps_build_id(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let original_id = build.id.clone();
let Json(rerolled) =
reroll_hero(State(state), Path(original_id.clone())).await.expect("reroll should succeed");
assert_eq!(rerolled.id, original_id);
// Enchantments must stay consistent with whichever hero ended up on the build.
for stage in &rerolled.enchantments {
if let Some(resolved) = &stage.resolved {
assert!(resolved.attributes.contains(&rerolled.hero.primary_attribute));
}
}
// Neutral items must have been re-rolled fresh (attempts populated again).
assert!(rerolled.neutral_items.iter().all(|s| s.attempts.len() == 2));
Ok(())
}
#[sqlx::test]
async fn reroll_item_slot_only_touches_the_requested_slot(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let Json(rerolled) = reroll_item_slot(State(state), Path((build.id.clone(), 3)))
.await
.expect("reroll should succeed");
for original_slot in &build.item_slots {
if original_slot.slot == 3 {
continue;
}
let same_slot = rerolled.item_slots.iter().find(|s| s.slot == original_slot.slot).unwrap();
assert_eq!(same_slot.item.as_ref().map(|i| i.id), original_slot.item.as_ref().map(|i| i.id));
}
Ok(())
}
#[sqlx::test]
async fn reroll_item_slot_one_stays_boots(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let Json(rerolled) =
reroll_item_slot(State(state), Path((build.id.clone(), 1))).await.expect("reroll should succeed");
let name = rerolled.item_slots[0].item.as_ref().unwrap().name.to_lowercase();
assert!(name.contains("boots") || name.contains("treads"));
Ok(())
}
#[sqlx::test]
async fn unknown_build_id_returns_not_found(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let result = reroll_hero(State(state), Path("does-not-exist".to_string())).await;
assert!(matches!(result, Err(AppError::NotFound)));
Ok(())
}
#[sqlx::test]
async fn patch_item_slot_marks_slot_manual(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool.clone());
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let (item_id,): (i32,) = sqlx::query_as("SELECT id FROM items WHERE source = 'shop' LIMIT 1")
.fetch_one(&pool)
.await?;
let Json(patched) = patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id }))
.await
.expect("patch should succeed");
let slot = patched.item_slots.iter().find(|s| s.slot == 2).unwrap();
assert!(slot.is_manual);
assert_eq!(slot.item.as_ref().unwrap().id, item_id);
Ok(())
}
#[sqlx::test]
async fn patch_item_slot_rejects_unknown_item_id(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let result =
patch_item_slot(State(state), Path((build.id.clone(), 1)), Json(ItemIdBody { item_id: -1 })).await;
assert!(matches!(result, Err(AppError::BadRequest(_))));
Ok(())
}
#[sqlx::test]
async fn aghanim_result_reflects_item_slots_after_manual_patch(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool.clone());
let mut config = test_config();
config.disable_aghanim_scepter_randomness = false;
let Json(build) = post_builds(State(state.clone()), Json(config)).await.unwrap();
let scepter_id: Option<(i32,)> =
sqlx::query_as("SELECT id FROM items WHERE name = 'Aghanim''s Scepter'").fetch_optional(&pool).await?;
let Some((scepter_id,)) = scepter_id else {
// Scepter isn't seeded in this environment -- nothing to verify.
return Ok(());
};
let Json(patched) =
patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id: scepter_id }))
.await
.expect("patch should succeed");
assert!(patched.aghanim_scepter.included);
assert_eq!(patched.aghanim_scepter.item.unwrap().id, scepter_id);
Ok(())
}
}
-268
View File
@@ -363,23 +363,6 @@ mod tests {
}
}
fn default_config() -> BuildConfig {
BuildConfig {
attack_types: vec![],
attributes: vec![],
item_tags: vec![],
disable_neutral_artifact_randomness: false,
disable_enchantment_randomness: false,
disable_all_neutral_rolls: false,
disabled_item_slots: vec![],
disable_aghanim_scepter_randomness: false,
disable_aghanim_shard_randomness: false,
neutral_tier_reroll_enabled: true,
aghanim_scepter_timing: AghanimTiming::Anytime,
aghanim_shard_timing: AghanimTiming::Anytime,
}
}
#[test]
fn pick_unused_item_avoids_already_used_ids() {
let pool = vec![sample_item(1), sample_item(2), sample_item(3)];
@@ -432,145 +415,6 @@ mod tests {
assert!(picked.is_empty());
}
#[sqlx::test]
async fn roll_neutral_stage_carries_forward_exact_item_when_gate_lost(pool: PgPool) -> sqlx::Result<()> {
let carried = sample_item(999);
let stage = roll_neutral_stage(&pool, 3, Some(carried.clone()), true, 0.0).await.expect("roll should succeed");
assert_eq!(stage.tier_gate_won, Some(false));
assert_eq!(stage.resolved.map(|i| i.id), Some(carried.id));
assert!(stage.attempts.is_empty());
assert!(!stage.requires_manual_pick);
Ok(())
}
#[sqlx::test]
async fn roll_hero_respects_attack_type_filter(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.attack_types = vec![crate::models::AttackType::Melee];
for _ in 0..10 {
let hero = roll_hero(&pool, &config).await.expect("a melee hero should exist");
assert_eq!(hero.attack_type, crate::models::AttackType::Melee);
}
Ok(())
}
#[sqlx::test]
async fn roll_hero_respects_attribute_filter(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.attributes = vec![PrimaryAttribute::Strength];
for _ in 0..10 {
let hero = roll_hero(&pool, &config).await.expect("a strength hero should exist");
assert_eq!(hero.primary_attribute, PrimaryAttribute::Strength);
}
Ok(())
}
#[sqlx::test(migrations = false)]
async fn roll_hero_errors_when_no_heroes_match(pool: PgPool) -> sqlx::Result<()> {
sqlx::raw_sql(
"CREATE TYPE primary_attribute AS ENUM ('strength', 'agility', 'intelligence', 'universal');
CREATE TYPE attack_type AS ENUM ('melee', 'ranged');
CREATE TABLE heroes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_attribute primary_attribute NOT NULL,
attack_type attack_type NOT NULL,
image_url TEXT NOT NULL
);",
)
.execute(&pool)
.await?;
let config = default_config();
let result = roll_hero(&pool, &config).await;
assert!(matches!(result, Err(AppError::BadRequest(_))));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_exclude_boots(pool: PgPool) -> sqlx::Result<()> {
let items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
assert!(items.iter().all(|i| !i.name.to_lowercase().contains("boots") && !i.name.to_lowercase().contains("treads")));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_respect_aghanim_toggles(pool: PgPool) -> sqlx::Result<()> {
let excluded = fetch_eligible_shop_items(&pool, &[], false, false).await.expect("query should succeed");
assert!(excluded.iter().all(|i| i.name != "Aghanim's Scepter" && i.name != "Aghanim's Shard"));
let included = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
// Not asserting presence (the seed data may or may not include them),
// just that the toggle doesn't filter them out when both are true.
assert!(included.len() >= excluded.len());
Ok(())
}
#[sqlx::test]
async fn boots_items_are_boots_only(pool: PgPool) -> sqlx::Result<()> {
let items = fetch_boots_items(&pool).await.expect("query should succeed");
assert!(items.iter().all(|i| i.name.to_lowercase().contains("boots") || i.name.to_lowercase().contains("treads")));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_filters_by_tag(pool: PgPool) -> sqlx::Result<()> {
let mut all_items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
for item in all_items.iter_mut() {
hydrate_item_tags(&pool, item).await.expect("hydrate should succeed");
}
let Some(sample_tag) = all_items.iter().flat_map(|i| i.tags.iter()).next().cloned() else {
return Ok(());
};
let mut filtered = fetch_eligible_shop_items(&pool, std::slice::from_ref(&sample_tag), true, true)
.await
.expect("query should succeed");
assert!(!filtered.is_empty());
for item in filtered.iter_mut() {
hydrate_item_tags(&pool, item).await.expect("hydrate should succeed");
assert!(item.tags.contains(&sample_tag));
}
Ok(())
}
#[sqlx::test]
async fn neutral_items_for_tier_stay_within_their_tier_range(pool: PgPool) -> sqlx::Result<()> {
for tier in 1..=5 {
let items = fetch_neutral_items_for_tier(&pool, tier).await.expect("query should succeed");
for item in items {
assert!(item.min_tier.unwrap() <= tier as i16);
assert!(item.max_tier.unwrap() >= tier as i16);
}
}
Ok(())
}
#[sqlx::test]
async fn enchantments_are_scoped_to_tier_and_attribute(pool: PgPool) -> sqlx::Result<()> {
for tier in 1..=5 {
for attribute in [
PrimaryAttribute::Strength,
PrimaryAttribute::Agility,
PrimaryAttribute::Intelligence,
PrimaryAttribute::Universal,
] {
let mut enchantments = fetch_enchantments_for_tier_and_attribute(&pool, tier, attribute)
.await
.expect("query should succeed");
for enchantment in enchantments.iter_mut() {
assert!(enchantment.min_tier <= tier as i16);
assert!(enchantment.max_tier >= tier as i16);
hydrate_enchantment_attributes(&pool, enchantment).await.expect("hydrate should succeed");
assert!(enchantment.attributes.contains(&attribute));
}
}
}
Ok(())
}
#[test]
fn derive_aghanim_result_finds_item_in_slots() {
let mut item = sample_item(42);
@@ -590,116 +434,4 @@ mod tests {
assert!(result.item.is_none());
}
#[sqlx::test]
async fn roll_item_slots_slot_one_is_always_boots(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
let slot1 = slots.iter().find(|s| s.slot == 1).unwrap();
let name = slot1.item.as_ref().expect("slot 1 should have an item").name.to_lowercase();
assert!(name.contains("boots") || name.contains("treads"), "slot 1 item '{name}' is not boots");
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_never_put_boots_outside_slot_one(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
for slot in slots.iter().filter(|s| s.slot != 1) {
if let Some(item) = &slot.item {
let name = item.name.to_lowercase();
assert!(!name.contains("boots") && !name.contains("treads"), "slot {} got boots item '{}'", slot.slot, item.name);
}
}
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_never_duplicates_when_enough_items_exist(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
assert_eq!(slots.len(), 6);
let ids: Vec<i32> = slots.iter().filter_map(|s| s.item.as_ref().map(|i| i.id)).collect();
let unique: HashSet<i32> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len(), "item slots should not repeat the same item when enough distinct items exist");
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_leaves_disabled_slots_empty_and_manual(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disabled_item_slots = vec![3, 5];
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
for slot in &slots {
if slot.slot == 3 || slot.slot == 5 {
assert!(slot.item.is_none());
assert!(slot.is_manual);
} else {
assert!(!slot.is_manual);
}
}
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disable_all_neutral_rolls = true;
let stages = roll_neutral_stages(&pool, &config, 0.5).await.expect("roll should succeed");
assert_eq!(stages.len(), 5);
assert!(stages.iter().all(|s| s.requires_manual_pick && s.resolved.is_none() && s.attempts.is_empty()));
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_always_produce_two_attempts_when_gate_always_wins(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let stages = roll_neutral_stages(&pool, &config, 1.0).await.expect("roll should succeed");
for stage in stages {
assert_eq!(stage.attempts.len(), 2, "tier {} should have 2 attempts", stage.tier);
assert!(stage.resolved.is_none(), "resolved should stay None until a manual pick");
assert!(stage.requires_manual_pick);
}
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_carry_forward_when_gate_always_lost(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.neutral_tier_reroll_enabled = false;
let stages = roll_neutral_stages(&pool, &config, 0.0).await.expect("roll should succeed");
// Tier 1 always rolls fresh (not gated); tiers 2-5 always lose the
// gate at chance=0.0, so they should carry tier 1's (unresolved) None.
assert_eq!(stages[0].tier, 1);
assert_eq!(stages[0].attempts.len(), 2);
for stage in &stages[1..] {
assert_eq!(stage.tier_gate_won, Some(false));
assert!(stage.attempts.is_empty());
}
Ok(())
}
#[sqlx::test]
async fn roll_enchantment_stages_are_guaranteed_when_enabled(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed");
for stage in stages {
assert!(stage.resolved.is_some(), "tier {} should have a guaranteed resolved enchantment", stage.tier);
assert!(!stage.requires_manual_pick);
}
Ok(())
}
#[sqlx::test]
async fn roll_enchantment_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disable_enchantment_randomness = true;
let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed");
assert!(stages.iter().all(|s| s.resolved.is_none() && s.requires_manual_pick));
Ok(())
}
}