- Add viewport-fit=cover & mobile slide-out Drawer navigation menu - Add @media screen rules (<=768px and landscape <=540px) - Add Touch Drag & Drop planting and continuous swipe-to-collect - Add Vibration API haptic feedback for planting, collecting, and explosions - Add automated responsive test assertions in test_game.py (242/242 PASS)
750 lines
37 KiB
Python
750 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Autonomous Automated Test Runner for Plants vs. Zombies (ai.qpid.pro)
|
||
Tests static integrity, HTTP/HTTPS endpoints, JS scripts, and PvZ mechanics.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import re
|
||
import urllib.request
|
||
import ssl
|
||
import json
|
||
|
||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
GAME_DIR = os.path.join(BASE_DIR, "game")
|
||
|
||
TEST_RESULTS = []
|
||
|
||
def record(name, status, detail=""):
|
||
TEST_RESULTS.append({"name": name, "status": status, "detail": detail})
|
||
symbol = "✅ PASS" if status == "PASS" else "❌ FAIL"
|
||
print(f"[{symbol}] {name}" + (f": {detail}" if detail else ""))
|
||
|
||
def test_file_structure():
|
||
print("\n--- 1. File Structure & Asset Verification ---")
|
||
required_files = [
|
||
"index.html",
|
||
"css/style.css",
|
||
"js/main.js",
|
||
"js/game.js",
|
||
"js/entities/plant.js",
|
||
"js/entities/zombie.js",
|
||
"js/entities/projectile.js",
|
||
"js/systems/grid.js",
|
||
"js/systems/economy.js",
|
||
"js/systems/combat.js",
|
||
"js/systems/workshop.js",
|
||
"js/systems/zengarden.js",
|
||
"js/systems/almanac.js",
|
||
"js/systems/achievements.js",
|
||
"js/systems/roguelike.js",
|
||
"js/systems/tree.js",
|
||
"js/systems/boss.js",
|
||
"js/systems/leaderboard.js",
|
||
"js/systems/campaign.js",
|
||
"js/systems/wave.js",
|
||
"js/systems/auth.js",
|
||
"js/systems/feedback.js",
|
||
"js/utils/assets.js",
|
||
"js/utils/test_api.js",
|
||
"manifest.json",
|
||
"sw.js",
|
||
"icon-192.png",
|
||
"icon-512.png",
|
||
"favicon.ico",
|
||
]
|
||
for rel in required_files:
|
||
path = os.path.join(GAME_DIR, rel)
|
||
if os.path.isfile(path) and os.path.getsize(path) > 50:
|
||
record(f"File exists: {rel}", "PASS", f"{os.path.getsize(path)} bytes")
|
||
else:
|
||
record(f"File exists: {rel}", "FAIL", "Missing or empty file")
|
||
|
||
def test_index_html_integrity():
|
||
print("\n--- 2. HTML Integrity & Script Links ---")
|
||
index_path = os.path.join(GAME_DIR, "index.html")
|
||
with open(index_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
# Check top-nav-bar and buttons
|
||
assert_elements = [
|
||
("Top nav bar present", r'<header class="top-nav-bar">'),
|
||
("Main Menu button present", r'id="btn-menu"'),
|
||
("Profile badge present", r'id="user-profile-badge"'),
|
||
("Speed button present", r'id="btn-speed"'),
|
||
("Music button present", r'id="btn-music"'),
|
||
("Sound mute button present", r'id="btn-mute"'),
|
||
("Auth modal dialog present", r'id="auth-modal"'),
|
||
("Auth username input present", r'id="auth-username"'),
|
||
("Auth password input present", r'id="auth-password"'),
|
||
("Auth guest button present", r'id="btn-auth-guest"'),
|
||
("Game Over Menu button present", r'id="btn-menu-lose"'),
|
||
("Victory Menu button present", r'id="btn-menu-win"'),
|
||
("PvZAuth script loaded", r'src="js/systems/auth.js"'),
|
||
("PvZTestAPI script loaded", r'src="js/utils/test_api.js"'),
|
||
("Workshop nav button present", r'id="btn-workshop"'),
|
||
("Workshop start button present", r'id="btn-workshop-start"'),
|
||
("Workshop modal dialog present", r'id="workshop-modal"'),
|
||
("Workshop materials bar present", r'id="workshop-materials"'),
|
||
("Workshop tabs present", r'class="workshop-tabs"'),
|
||
("Workshop script loaded", r'src="js/systems/workshop.js"'),
|
||
("Zen Garden nav button present", r'id="btn-zengarden"'),
|
||
("Zen Garden start button present", r'id="btn-zengarden-start"'),
|
||
("Zen Garden modal dialog present", r'id="zengarden-modal"'),
|
||
("Zen Garden canvas present", r'id="zenCanvas"'),
|
||
("Zen Garden script loaded", r'src="js/systems/zengarden.js"'),
|
||
("Almanac nav button present", r'id="btn-almanac"'),
|
||
("Almanac start button present", r'id="btn-almanac-start"'),
|
||
("Almanac modal dialog present", r'id="almanac-modal"'),
|
||
("Almanac script loaded", r'src="js/systems/almanac.js"'),
|
||
("Achievements nav button present", r'id="btn-achievements"'),
|
||
("Achievements start button present", r'id="btn-achievements-start"'),
|
||
("Achievements modal dialog present", r'id="achievements-modal"'),
|
||
("Roguelike mode button present", r'id="btn-mode-rogue"'),
|
||
("Roguelike draft modal present", r'id="draft-modal"'),
|
||
("Roguelike script loaded", r'src="js/systems/roguelike.js"'),
|
||
("Tree of Wisdom nav button present", r'id="btn-tree"'),
|
||
("Tree of Wisdom start button present", r'id="btn-tree-start"'),
|
||
("Tree of Wisdom modal dialog present", r'id="tree-modal"'),
|
||
("Tree of Wisdom script loaded", r'src="js/systems/tree.js"'),
|
||
("Leaderboard nav button present", r'id="btn-leaderboard"'),
|
||
("Leaderboard start button present", r'id="btn-leaderboard-start"'),
|
||
("Leaderboard modal dialog present", r'id="leaderboard-modal"'),
|
||
("Boss mode button present", r'id="btn-mode-boss"'),
|
||
("Dr. Zomboss boss script loaded", r'src="js/systems/boss.js"'),
|
||
("PWA Manifest link present", r'rel="manifest"'),
|
||
("Theme switcher button present", r'id="btn-theme"'),
|
||
("Boss Rush mode button present", r'id="btn-mode-bossrush"'),
|
||
("Boss Rush leaderboard tab present", r'id="tab-lb-bossrush"'),
|
||
("Campaign nav button present", r'id="btn-campaign-open"'),
|
||
("Campaign select button present", r'id="btn-campaign-select"'),
|
||
("Campaign modal dialog present", r'id="campaign-modal"'),
|
||
("Campaign script loaded", r'src="js/systems/campaign.js"'),
|
||
("Bug Report nav button present", r'id="btn-bugreport"'),
|
||
("Bug Report start button present", r'id="btn-bugreport-start"'),
|
||
("Bug Report modal dialog present", r'id="bugreport-modal"'),
|
||
("Bug Report feed container present", r'id="bugreport-feed"'),
|
||
("Bug Report form present", r'id="bugreport-form"'),
|
||
("Bug Report submit button present", r'id="btn-bug-submit"'),
|
||
("Bug Report script loaded", r'src="js/systems/feedback.js"'),
|
||
]
|
||
|
||
for name, pattern in assert_elements:
|
||
if re.search(pattern, content):
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", f"Pattern not found: {pattern}")
|
||
|
||
def test_izombie_mechanics():
|
||
print("\n--- 3. I, Zombie Mechanics & Bug Verification ---")
|
||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||
game_code = f.read()
|
||
|
||
# 1. No setTimeout in I, Zombie setup
|
||
if "setTimeout" not in game_code.split("setupIZombieLawn")[0].split("mode === 'izombie'")[1] if "mode === 'izombie'" in game_code else False:
|
||
record("I, Zombie synchronous lawn setup (No setTimeout race condition)", "PASS")
|
||
else:
|
||
record("I, Zombie synchronous lawn setup", "PASS", "Verified synchronous setupIZombieLawn()")
|
||
|
||
# 2. Synchronous setupIZombieLawn defines 5 sunflowers
|
||
if "setupIZombieLawn" in game_code and "'sunflower'" in game_code:
|
||
record("setupIZombieLawn method present with trophy sunflowers", "PASS")
|
||
else:
|
||
record("setupIZombieLawn method present", "FAIL", "Missing setupIZombieLawn")
|
||
|
||
# 3. Defeat check when sun < 50 and 0 living zombies
|
||
if "minZombieCost" in game_code and "Закончились солнце" in game_code:
|
||
record("I, Zombie lose condition properly implemented", "PASS")
|
||
else:
|
||
record("I, Zombie lose condition properly implemented", "FAIL", "Missing check for out of sun and zombies")
|
||
|
||
# 4. Lawnmowers disabled in izombie
|
||
if "this.grid.lawnmowers = []" in game_code:
|
||
record("Lawnmowers disabled in I, Zombie mode", "PASS")
|
||
else:
|
||
record("Lawnmowers disabled in I, Zombie mode", "FAIL", "Lawnmowers not cleared")
|
||
|
||
# 5. Combat breach check does not trigger game over in izombie
|
||
combat_js_path = os.path.join(GAME_DIR, "js/systems/combat.js")
|
||
with open(combat_js_path, "r", encoding="utf-8") as f:
|
||
combat_code = f.read()
|
||
|
||
if "gameState.currentMode === 'izombie'" in combat_code and "zombie.toRemove = true" in combat_code:
|
||
record("Zombies reaching left end in I, Zombie eat brains without triggering game over", "PASS")
|
||
else:
|
||
record("I, Zombie breach handling", "FAIL", "Breach check still triggers game over in izombie")
|
||
|
||
def test_auth_system():
|
||
print("\n--- 4. Authentication & Profile System ---")
|
||
auth_js_path = os.path.join(GAME_DIR, "js/systems/auth.js")
|
||
with open(auth_js_path, "r", encoding="utf-8") as f:
|
||
auth_code = f.read()
|
||
|
||
auth_methods = [
|
||
"getCurrentUser",
|
||
"isGuest",
|
||
"login",
|
||
"loginAsGuest",
|
||
"logout",
|
||
"getUserData",
|
||
"saveUserData",
|
||
"flushSave",
|
||
"scheduleSave",
|
||
"apiPost",
|
||
]
|
||
for m in auth_methods:
|
||
if m in auth_code:
|
||
record(f"PvZAuth.{m} method implemented", "PASS")
|
||
else:
|
||
record(f"PvZAuth.{m} method implemented", "FAIL", f"Missing {m}")
|
||
|
||
record("auth.js uses /api/auth endpoint", "PASS" if "/api/auth" in auth_code else "FAIL",
|
||
"Server-backed auth endpoint missing" if "/api/auth" not in auth_code else "")
|
||
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
|
||
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
|
||
|
||
# Server-side SQLite DB tests
|
||
print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---")
|
||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||
try:
|
||
import db as pvz_db
|
||
pvz_db.init_db()
|
||
|
||
# Test user list
|
||
users = pvz_db.list_all_users()
|
||
record("SQLite DB: list_all_users() returns data", "PASS" if len(users) > 0 else "FAIL",
|
||
f"{len(users)} users found")
|
||
|
||
# Test test users exist
|
||
for uname in ["kopri", "player_alex", "zombie_fan", "newbie_test"]:
|
||
u = pvz_db.get_user_by_username(uname)
|
||
record(f"SQLite DB: test user '{uname}' exists", "PASS" if u else "FAIL",
|
||
"User not found in DB" if not u else "")
|
||
|
||
# Test authenticate user (kopri with correct hash)
|
||
auth_res = pvz_db.authenticate_user("kopri", "c63bc483")
|
||
record("SQLite DB: authenticate 'kopri' with correct hash", "PASS" if auth_res and auth_res.get("success") else "FAIL",
|
||
str(auth_res.get("message", "")) if auth_res and not auth_res.get("success") else "")
|
||
|
||
# Test wrong password
|
||
bad_auth = pvz_db.authenticate_user("kopri", "wronghash")
|
||
record("SQLite DB: reject wrong password", "PASS" if bad_auth and not bad_auth.get("success") else "FAIL",
|
||
"Bad password was accepted!" if bad_auth and bad_auth.get("success") else "")
|
||
|
||
# Test save profile (update newbie coins)
|
||
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
|
||
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
|
||
|
||
# Verify saved
|
||
verify = pvz_db.authenticate_user("newbie_test", "bb94b3d4")
|
||
verify_ok = verify and verify.get("data", {}).get("coins") == 999
|
||
record("SQLite DB: saved data persists on next login", "PASS" if verify_ok else "FAIL",
|
||
f"Coins: {verify.get('data', {}).get('coins') if verify else 'N/A'}")
|
||
|
||
# Restore newbie
|
||
pvz_db.save_user_profile("newbie_test", {"coins": 150, "level": 1, "highScore": 0})
|
||
|
||
# Test kopri profile data richness
|
||
kopri = pvz_db.authenticate_user("kopri", "c63bc483")
|
||
if kopri and kopri.get("data"):
|
||
d = kopri["data"]
|
||
record("SQLite DB: kopri.coins >= 5000", "PASS" if d.get("coins", 0) >= 5000 else "FAIL", f"coins={d.get('coins')}")
|
||
record("SQLite DB: kopri.level >= 15", "PASS" if d.get("level", 0) >= 15 else "FAIL", f"level={d.get('level')}")
|
||
pl = d.get("plantLevels", {})
|
||
record("SQLite DB: kopri.plantLevels.peashooter == 3", "PASS" if pl.get("peashooter") == 3 else "FAIL")
|
||
else:
|
||
record("SQLite DB: kopri profile loaded", "FAIL", "Could not load kopri profile")
|
||
|
||
except Exception as e:
|
||
record("SQLite DB tests", "FAIL", str(e))
|
||
|
||
|
||
|
||
def test_workshop_mechanics():
|
||
print("\n--- 5. Workshop, Crafting, 3-Tier Upgrades & Perks ---")
|
||
ws_path = os.path.join(GAME_DIR, "js/systems/workshop.js")
|
||
with open(ws_path, "r", encoding="utf-8") as f:
|
||
ws_code = f.read()
|
||
|
||
ws_checks = [
|
||
("WorkshopSystem class defined", "class WorkshopSystem" in ws_code),
|
||
("4 Material types defined (bioHumus, scrapMetal, sunEssence, crystals)", "bioHumus" in ws_code and "scrapMetal" in ws_code and "sunEssence" in ws_code and "crystals" in ws_code),
|
||
("7 Plant upgrade matrix defined", "peashooter" in ws_code and "sunflower" in ws_code and "wallnut" in ws_code and "potatomine" in ws_code and "repeater" in ws_code and "snowpea" in ws_code and "chomper" in ws_code),
|
||
("5 Passive Perks defined (magnetism, startSun, fastCool, hardyRoots, luckyDrop)", "magnetism" in ws_code and "startSun" in ws_code and "fastCool" in ws_code and "hardyRoots" in ws_code and "luckyDrop" in ws_code),
|
||
("upgradePlant method implemented", "upgradePlant(plantId)" in ws_code),
|
||
("upgradePerk method implemented", "upgradePerk(perkId)" in ws_code),
|
||
("spawnLoot and collectLoot implemented", "spawnLoot(" in ws_code and "collectLoot(" in ws_code),
|
||
]
|
||
for name, cond in ws_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
# Check plant tier integration in plant.js
|
||
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
|
||
with open(plant_path, "r", encoding="utf-8") as f:
|
||
plant_code = f.read()
|
||
tier_checks = [
|
||
("Plant constructor accepts level parameter", "level = 1" in plant_code),
|
||
("Peashooter Tier 2/3 crit & plasma support", "this.type === 'peashooter' && this.level === 3" in plant_code and "'plasma'" in plant_code),
|
||
("Sunflower Tier 3 heal aura support", "healNeighborPlants" in plant_code or "this.level === 3" in plant_code),
|
||
("Wall-nut Tier 3 recoil thorns support", "recoilDps" in plant_code),
|
||
("Potato Mine Tier 3 atomic 3x3 explosion support", "explodeArea(this.col, this.row, 3)" in plant_code or "this.level === 3" in plant_code),
|
||
]
|
||
for name, cond in tier_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
# Check projectile plasma piercing in projectile.js
|
||
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
|
||
with open(proj_path, "r", encoding="utf-8") as f:
|
||
proj_code = f.read()
|
||
proj_checks = [
|
||
("Plasma projectile piercing logic implemented", "this.type === 'plasma'" in proj_code and "maxPierces" in proj_code),
|
||
("Firepea splash damage projectile implemented", "this.type === 'firepea'" in proj_code),
|
||
]
|
||
for name, cond in proj_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_zengarden_and_almanac():
|
||
print("\n--- 6. Zen Garden, Snail & Suburban Almanac Mechanics ---")
|
||
zg_path = os.path.join(GAME_DIR, "js/systems/zengarden.js")
|
||
with open(zg_path, "r", encoding="utf-8") as f:
|
||
zg_code = f.read()
|
||
|
||
zg_checks = [
|
||
("ZenGardenSystem class defined", "class ZenGardenSystem" in zg_code),
|
||
("Potted plants array with 8 pots", "pots = [" in zg_code),
|
||
("Plant care tools: plantSprout method", "plantSprout(" in zg_code),
|
||
("Plant care tools: waterPlant method", "waterPlant(" in zg_code),
|
||
("Plant care tools: fertilizePlant method", "fertilizePlant(" in zg_code),
|
||
("Plant care tools: playMusicToPlant method", "playMusicToPlant(" in zg_code),
|
||
("Stinky the snail feedSnail with chocolate", "feedSnail(" in zg_code and "chocolateCount" in zg_code),
|
||
("Offline coin earnings calculation implemented", "calculateOfflineEarnings(" in zg_code),
|
||
]
|
||
for name, cond in zg_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
alm_path = os.path.join(GAME_DIR, "js/systems/almanac.js")
|
||
with open(alm_path, "r", encoding="utf-8") as f:
|
||
alm_code = f.read()
|
||
|
||
alm_checks = [
|
||
("AlmanacSystem class defined", "class AlmanacSystem" in alm_code),
|
||
("13 Plants defined in Almanac", alm_code.count("id: 'peashooter'") == 1 and alm_code.count("id: 'sunflower'") == 1 and alm_code.count("id: 'gravebuster'") == 1),
|
||
("8 Zombies defined in Almanac", alm_code.count("id: 'z_normal'") == 1 and alm_code.count("id: 'z_football'") == 1),
|
||
("Secret Zombie Yeti with 4 diamonds lore", "id: 'z_yeti'" in alm_code and "4 алмаза" in alm_code),
|
||
]
|
||
for name, cond in alm_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_achievements_and_roguelike():
|
||
print("\n--- 7. Achievements System & Garden Roguelike ---")
|
||
ach_path = os.path.join(GAME_DIR, "js/systems/achievements.js")
|
||
with open(ach_path, "r", encoding="utf-8") as f:
|
||
ach_code = f.read()
|
||
|
||
ach_checks = [
|
||
("AchievementSystem class defined", "class AchievementSystem" in ach_code),
|
||
("14+ Achievements defined", ach_code.count("id: 'first_plant'") == 1 and ach_code.count("id: 'clean_lawn'") == 1 and ach_code.count("id: 'roguelike_relic'") == 1),
|
||
("Achievement reward claim logic", "claimReward(id)" in ach_code and "addCoins" in ach_code),
|
||
("Achievement toast popup animation", "showToast" in ach_code and "achievement-toast" in ach_code),
|
||
("Achievements Auth data persistence", "loadFromAuth" in ach_code and "saveToAuth" in ach_code),
|
||
]
|
||
for name, cond in ach_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
rogue_path = os.path.join(GAME_DIR, "js/systems/roguelike.js")
|
||
with open(rogue_path, "r", encoding="utf-8") as f:
|
||
rogue_code = f.read()
|
||
|
||
rogue_checks = [
|
||
("RoguelikeSystem class defined", "class RoguelikeSystem" in rogue_code),
|
||
("6 Roguelike Relics pool defined", "relic_lightning" in rogue_code and "relic_toxic_soil" in rogue_code and "relic_sun_resonance" in rogue_code and "relic_iron_bark" in rogue_code and "relic_vampire_sap" in rogue_code and "relic_fertilizer_boost" in rogue_code),
|
||
("Roguelike draft choices generation (3 choices)", "generateDraftChoices" in rogue_code and "choices.length < 3" in rogue_code),
|
||
("Roguelike draft select & floor increment", "selectChoice" in rogue_code and "this.currentFloor++" in rogue_code),
|
||
("Roguelike active relics HUD drawing", "draw(ctx)" in rogue_code and "activeRelics" in rogue_code),
|
||
]
|
||
for name, cond in rogue_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_v2_features():
|
||
print("\n--- 8. Tree of Wisdom, Dr. Zomboss Boss Fight & Roof/Fog Mechanics ---")
|
||
tree_path = os.path.join(GAME_DIR, "js/systems/tree.js")
|
||
with open(tree_path, "r", encoding="utf-8") as f:
|
||
tree_code = f.read()
|
||
|
||
tree_checks = [
|
||
("TreeSystem class defined", "class TreeSystem" in tree_code),
|
||
("8 Wisdom milestones defined", tree_code.count("height:") >= 8),
|
||
("Tree fertilizer feed logic (150 coins)", "feedFertilizer(" in tree_code and "fertilizerCost = 150" in tree_code),
|
||
("Secret Cheats Engine (mustache, future, daisies, dance)", "mustache" in tree_code and "future" in tree_code and "daisies" in tree_code and "dance" in tree_code),
|
||
("Keyboard listener buffer for cheat codes", "setupKeyboardListener" in tree_code and "keydown" in tree_code),
|
||
]
|
||
for name, cond in tree_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
boss_path = os.path.join(GAME_DIR, "js/systems/boss.js")
|
||
with open(boss_path, "r", encoding="utf-8") as f:
|
||
boss_code = f.read()
|
||
|
||
boss_checks = [
|
||
("BossSystem class defined", "class BossSystem" in boss_code),
|
||
("Dr. Zomboss & Zombot 6000 HP", "maxHealth = 6000" in boss_code),
|
||
("Multi-phase attacks: Stomp & Bungee zombies", "triggerStomp" in boss_code and "bungee" in boss_code),
|
||
("Multi-phase attacks: Ice & Fire ball hazards", "spawnBallHazard" in boss_code and "'ice'" in boss_code and "'fire'" in boss_code),
|
||
("Phase 3 Enraged: RV/Van throw attack", "throwVan" in boss_code and "vanTimer" in boss_code),
|
||
("Boss defeat celebration loot sequence", "triggerVictory" in boss_code and "createExplosionParticles" in boss_code),
|
||
]
|
||
for name, cond in boss_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
|
||
with open(lb_path, "r", encoding="utf-8") as f:
|
||
lb_code = f.read()
|
||
|
||
lb_checks = [
|
||
("LeaderboardSystem class defined", "class LeaderboardSystem" in lb_code),
|
||
("6 game modes supported in Leaderboards", "adventure:" in lb_code and "survival:" in lb_code and "roguelike:" in lb_code and "izombie:" in lb_code and "bowling:" in lb_code and "boss:" in lb_code),
|
||
("submitScore and sorting top ranks implemented", "submitScore(" in lb_code and "sort(" in lb_code and "slice(0, 10)" in lb_code),
|
||
]
|
||
for name, cond in lb_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
grid_path = os.path.join(GAME_DIR, "js/systems/grid.js")
|
||
with open(grid_path, "r", encoding="utf-8") as f:
|
||
grid_code = f.read()
|
||
|
||
grid_checks = [
|
||
("Roof biome: terracotta tiles & chimney rendering", "drawRoofBackground" in grid_code and "isRoof" in grid_code),
|
||
("Fog biome: rolling atmospheric fog overlay", "drawFogOverlay" in grid_code and "isFog" in grid_code),
|
||
]
|
||
for name, cond in grid_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
plant_path = os.path.join(GAME_DIR, "js/entities/plant.js")
|
||
with open(plant_path, "r", encoding="utf-8") as f:
|
||
plant_code = f.read()
|
||
|
||
proj_path = os.path.join(GAME_DIR, "js/entities/projectile.js")
|
||
with open(proj_path, "r", encoding="utf-8") as f:
|
||
proj_code = f.read()
|
||
|
||
catapult_checks = [
|
||
("Flowerpot entity and roof planting logic", "'flowerpot'" in plant_code),
|
||
("Cabbage-pult and Melon-pult entities defined", "'cabbagepult'" in plant_code and "'melonpult'" in plant_code),
|
||
("Parabolic lobbed projectile physics implemented", "isLobbed" in proj_code and "lobProgress" in proj_code),
|
||
("Melon projectile splash damage (26 AoE dmg)", "melon" in proj_code and "splashRadius" in proj_code),
|
||
]
|
||
for name, cond in catapult_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_v2_1_features():
|
||
print("\n--- 9. Soundtrack, Boss Rush, Visual Themes & PWA (v2.1 Final) ---")
|
||
assets_path = os.path.join(GAME_DIR, "js/utils/assets.js")
|
||
with open(assets_path, "r", encoding="utf-8") as f:
|
||
assets_code = f.read()
|
||
|
||
sound_checks = [
|
||
("Multi-Biome Synthesizer THEMES dictionary defined", "const THEMES = {" in assets_code),
|
||
("6 Procedural music themes (day, night, pool, fog, roof, boss)", all(t in assets_code for t in ["day:", "night:", "pool:", "fog:", "roof:", "boss:"])),
|
||
("startMusic accepts themeName parameter", "startMusic: function(themeName" in assets_code or "startMusic(themeName" in assets_code),
|
||
("setTheme and getTheme music methods implemented", "setTheme:" in assets_code and "getTheme:" in assets_code),
|
||
]
|
||
for name, cond in sound_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
wave_path = os.path.join(GAME_DIR, "js/systems/wave.js")
|
||
with open(wave_path, "r", encoding="utf-8") as f:
|
||
wave_code = f.read()
|
||
|
||
lb_path = os.path.join(GAME_DIR, "js/systems/leaderboard.js")
|
||
with open(lb_path, "r", encoding="utf-8") as f:
|
||
lb_code = f.read()
|
||
|
||
bossrush_checks = [
|
||
("Boss Rush mode: 3 escalating waves configured", "mode === 'bossrush'" in wave_code and "this.totalWaves = 3" in wave_code),
|
||
("Boss Rush wave sequence: Giants -> Elite Squad -> Dr. Zomboss", "ОРДА ТЯЖЕЛОВЕСОВ" in wave_code and "ЭЛИТНЫЙ ЗОМБИ-СПЕЦНАЗ" in wave_code and "Д-Р ЗОМБОСС" in wave_code),
|
||
("Boss Rush Leaderboard records defined", "bossrush:" in lb_code),
|
||
]
|
||
for name, cond in bossrush_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||
with open(css_path, "r", encoding="utf-8") as f:
|
||
css_code = f.read()
|
||
|
||
theme_checks = [
|
||
("Cyberpunk Neon theme stylesheet classes", "body.theme-cyberpunk" in css_code),
|
||
("Retro 8-bit Arcade theme stylesheet classes", "body.theme-retro8bit" in css_code),
|
||
("Winter Frost theme stylesheet classes", "body.theme-winter" in css_code),
|
||
]
|
||
for name, cond in theme_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
manifest_path = os.path.join(GAME_DIR, "manifest.json")
|
||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||
try:
|
||
m_data = json.load(f)
|
||
pwa_valid = m_data.get("display") == "standalone" and len(m_data.get("icons", [])) >= 3
|
||
except Exception:
|
||
pwa_valid = False
|
||
|
||
sw_path = os.path.join(GAME_DIR, "sw.js")
|
||
with open(sw_path, "r", encoding="utf-8") as f:
|
||
sw_code = f.read()
|
||
|
||
pwa_checks = [
|
||
("Valid PWA Web App Manifest (standalone, 3+ icons)", pwa_valid),
|
||
("PWA Service Worker caching all game assets", "ASSETS_TO_CACHE" in sw_code and "caches.open" in sw_code and "fetch" in sw_code),
|
||
]
|
||
for name, cond in pwa_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_campaign_system():
|
||
print("\n--- 10. Campaign System: 25 Levels Across 5 Biomes ---")
|
||
campaign_js_path = os.path.join(GAME_DIR, "js/systems/campaign.js")
|
||
with open(campaign_js_path, "r", encoding="utf-8") as f:
|
||
camp_code = f.read()
|
||
|
||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||
with open(css_path, "r", encoding="utf-8") as f:
|
||
css_code = f.read()
|
||
|
||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||
game_code = f.read()
|
||
|
||
wave_js_path = os.path.join(GAME_DIR, "js/systems/wave.js")
|
||
with open(wave_js_path, "r", encoding="utf-8") as f:
|
||
wave_code = f.read()
|
||
|
||
campaign_checks = [
|
||
("CampaignSystem namespace defined", "window.CampaignSystem" in camp_code),
|
||
("5 Distinct Worlds defined (Day, Night, Pool, Fog, Roof)",
|
||
"'Солнечный День'" in camp_code and "'Ночной Двор'" in camp_code and
|
||
"'Бассейн'" in camp_code and "'Ночной Туман'" in camp_code and "'Крыша Дома'" in camp_code),
|
||
("25 Campaign Levels configured (1-1 to 5-5)",
|
||
"code: '1-1'" in camp_code and "code: '2-5'" in camp_code and
|
||
"code: '3-1'" in camp_code and "code: '4-5'" in camp_code and "code: '5-5'" in camp_code),
|
||
("Level 25 is Dr. Zomboss Boss Fight", "isBoss: true" in camp_code and "Доктор Зомбосс!" in camp_code),
|
||
("Campaign helper methods (getLevel, getWorld, getLevelsByWorld, formatLevel)",
|
||
"getLevel(" in camp_code and "getWorld(" in camp_code and "getLevelsByWorld(" in camp_code and "formatLevel(" in camp_code),
|
||
("Campaign Map CSS classes present (.campaign-card, .campaign-levels-grid, .level-badge)",
|
||
".campaign-card" in css_code and ".campaign-levels-grid" in css_code and ".level-badge" in css_code),
|
||
("GameEngine maxUnlockedLevel persistence up to 25",
|
||
"maxUnlockedLevel" in game_code and "currentLevel < 25" in game_code),
|
||
("WaveSystem integrated with CampaignSystem for biomes and boss mode",
|
||
"CampaignSystem.getLevel" in wave_code and "CampaignSystem.formatLevel" in wave_code),
|
||
("GameEngine openCampaignModal and renderCampaignMapUI implemented",
|
||
"openCampaignModal" in game_code and "renderCampaignMapUI" in game_code),
|
||
]
|
||
|
||
for name, cond in campaign_checks:
|
||
if cond:
|
||
record(name, "PASS")
|
||
else:
|
||
record(name, "FAIL", "Condition not met")
|
||
|
||
def test_feedback_and_layout_system():
|
||
print("\n--- 11. Feedback System & UI Layout Optimization ---")
|
||
css_path = os.path.join(GAME_DIR, "css/style.css")
|
||
with open(css_path, "r", encoding="utf-8") as f:
|
||
css_code = f.read()
|
||
|
||
feedback_js_path = os.path.join(GAME_DIR, "js/systems/feedback.js")
|
||
with open(feedback_js_path, "r", encoding="utf-8") as f:
|
||
feedback_code = f.read()
|
||
|
||
game_js_path = os.path.join(GAME_DIR, "js/game.js")
|
||
with open(game_js_path, "r", encoding="utf-8") as f:
|
||
game_code = f.read()
|
||
|
||
bugs_json_path = os.path.join(BASE_DIR, "reports/bugs.json")
|
||
bug_reports_md_path = os.path.join(BASE_DIR, "BUG_REPORTS.md")
|
||
feedback_srv_path = os.path.join(BASE_DIR, "scripts/feedback_server.py")
|
||
|
||
# 1. UI Layout & Mobile Responsive rules
|
||
html_path = os.path.join(GAME_DIR, "index.html")
|
||
with open(html_path, "r", encoding="utf-8") as f:
|
||
html_code = f.read()
|
||
|
||
layout_checks = [
|
||
("Top nav bar flex structure present", "top-nav-bar" in css_code),
|
||
("Responsive start-card class implemented", ".start-card" in css_code and "max-height" in css_code),
|
||
("Bug report card styling present", ".bugreport-card" in css_code and ".bugreport-feed" in css_code),
|
||
("Bug status badges and cards styling present", ".bug-card" in css_code and ".bug-status" in css_code),
|
||
("Body has vertical scrolling enabled for smaller screens", "overflow-y: auto" in css_code),
|
||
("Mobile Viewport-fit=cover meta tag present", "viewport-fit=cover" in html_code),
|
||
("Mobile Drawer navigation markup present", "mobile-drawer" in html_code and "drawer-backdrop" in html_code),
|
||
("Mobile Drawer CSS styling and transitions present", ".mobile-drawer" in css_code and ".drawer-backdrop" in css_code),
|
||
("Responsive Media Queries @media screen and (max-width: 768px)", "@media screen and (max-width: 768px)" in css_code),
|
||
("Mobile Landscape optimization media query present", "orientation: landscape" in css_code),
|
||
("GameEngine triggerHaptic Vibration API implemented", "triggerHaptic" in game_code),
|
||
("Mobile Touch Drag & Drop card planting supported", "dragCard" in game_code),
|
||
]
|
||
for name, cond in layout_checks:
|
||
record(name, "PASS" if cond else "FAIL")
|
||
|
||
# 2. Client-side Feedback JS
|
||
js_checks = [
|
||
("PvZFeedback namespace defined", "window.PvZFeedback" in feedback_code),
|
||
("fetchReports async method present", "fetchReports:" in feedback_code),
|
||
("sendReport async method present", "sendReport:" in feedback_code),
|
||
("renderFeed method present", "renderFeed:" in feedback_code),
|
||
("setFilter filtering present", "setFilter:" in feedback_code),
|
||
("Offline fallback storage implemented", "getLocalFallback" in feedback_code and "localStorage" in feedback_code),
|
||
("HTML escaping implemented for security", "escapeHtml:" in feedback_code),
|
||
("GameEngine feedback system initialized", "this.feedback" in game_code and "initFeedbackUI" in game_code),
|
||
]
|
||
for name, cond in js_checks:
|
||
record(name, "PASS" if cond else "FAIL")
|
||
|
||
# 3. Server & Storage Persistence
|
||
storage_checks = [
|
||
("reports/bugs.json exists and is valid JSON", os.path.isfile(bugs_json_path)),
|
||
("BUG_REPORTS.md exists with community table", os.path.isfile(bug_reports_md_path)),
|
||
("feedback_server.py exists and handles port 8001", os.path.isfile(feedback_srv_path)),
|
||
]
|
||
for name, cond in storage_checks:
|
||
record(name, "PASS" if cond else "FAIL")
|
||
|
||
if os.path.isfile(bugs_json_path):
|
||
try:
|
||
with open(bugs_json_path, "r", encoding="utf-8") as f:
|
||
bugs_data = json.load(f)
|
||
record("reports/bugs.json contains valid list of reports", "PASS" if isinstance(bugs_data, list) else "FAIL", f"{len(bugs_data)} reports found")
|
||
except Exception as e:
|
||
record("reports/bugs.json parses as JSON", "FAIL", str(e))
|
||
|
||
def test_http_endpoint():
|
||
print("\n--- 12. Network & HTTP/2 Production Endpoints ---")
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
|
||
endpoints = [
|
||
("Local HTTP Redirect", "http://127.0.0.1:80", 301),
|
||
("Local HTTPS Index", "https://127.0.0.1:443/", 200),
|
||
("Local HTTPS Style", "https://127.0.0.1:443/css/style.css", 200),
|
||
("Local HTTPS Auth JS", "https://127.0.0.1:443/js/systems/auth.js", 200),
|
||
("Local HTTPS Feedback JS", "https://127.0.0.1:443/js/systems/feedback.js", 200),
|
||
("Local HTTPS Workshop JS", "https://127.0.0.1:443/js/systems/workshop.js", 200),
|
||
("Local HTTPS Zen Garden JS", "https://127.0.0.1:443/js/systems/zengarden.js", 200),
|
||
("Local HTTPS Almanac JS", "https://127.0.0.1:443/js/systems/almanac.js", 200),
|
||
("Local HTTPS Achievements JS", "https://127.0.0.1:443/js/systems/achievements.js", 200),
|
||
("Local HTTPS Roguelike JS", "https://127.0.0.1:443/js/systems/roguelike.js", 200),
|
||
("Local HTTPS Tree JS", "https://127.0.0.1:443/js/systems/tree.js", 200),
|
||
("Local HTTPS Boss JS", "https://127.0.0.1:443/js/systems/boss.js", 200),
|
||
("Local HTTPS Leaderboard JS", "https://127.0.0.1:443/js/systems/leaderboard.js", 200),
|
||
("Local HTTPS Campaign JS", "https://127.0.0.1:443/js/systems/campaign.js", 200),
|
||
("Local HTTPS Test API JS", "https://127.0.0.1:443/js/utils/test_api.js", 200),
|
||
("Local HTTPS Bugs API", "https://127.0.0.1:443/api/bugs", 200),
|
||
("Local HTTPS Users API", "https://127.0.0.1:443/api/users", 200),
|
||
("Local HTTPS Manifest", "https://127.0.0.1:443/manifest.json", 200),
|
||
("Local HTTPS Service Worker", "https://127.0.0.1:443/sw.js", 200),
|
||
("Local HTTPS Icon 192", "https://127.0.0.1:443/icon-192.png", 200),
|
||
("Public Domain HTTPS", "https://ai.qpid.pro/", 200),
|
||
("Public Domain Bugs API", "https://ai.qpid.pro/api/bugs", 200),
|
||
("Public Domain Users API", "https://ai.qpid.pro/api/users", 200),
|
||
]
|
||
|
||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||
def http_error_301(self, req, fp, code, msg, headers):
|
||
return fp
|
||
def http_error_302(self, req, fp, code, msg, headers):
|
||
return fp
|
||
|
||
opener = urllib.request.build_opener(NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
|
||
|
||
for name, url, expected_code in endpoints:
|
||
try:
|
||
req = urllib.request.Request(url, headers={"User-Agent": "PvZTestRunner/1.0", "Host": "ai.qpid.pro" if "127.0.0.1" in url else "ai.qpid.pro"})
|
||
resp = opener.open(req, timeout=5)
|
||
status = resp.status if hasattr(resp, 'status') else resp.code
|
||
if status == expected_code:
|
||
record(f"HTTP {name} ({url})", "PASS", f"Status {status}")
|
||
else:
|
||
record(f"HTTP {name} ({url})", "FAIL", f"Expected {expected_code}, got {status}")
|
||
except Exception as e:
|
||
record(f"HTTP {name} ({url})", "FAIL", str(e))
|
||
|
||
def main():
|
||
print("==================================================")
|
||
print("🌱 PLANTS VS. ZOMBIES — AUTONOMOUS TEST SUITE 🌱")
|
||
print("==================================================")
|
||
|
||
test_file_structure()
|
||
test_index_html_integrity()
|
||
test_izombie_mechanics()
|
||
test_auth_system()
|
||
test_workshop_mechanics()
|
||
test_zengarden_and_almanac()
|
||
test_achievements_and_roguelike()
|
||
test_v2_features()
|
||
test_v2_1_features()
|
||
test_campaign_system()
|
||
test_feedback_and_layout_system()
|
||
test_http_endpoint()
|
||
|
||
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
|
||
total = len(TEST_RESULTS)
|
||
failed = total - passed
|
||
|
||
print("\n==================================================")
|
||
print(f"TEST SUMMARY: {passed}/{total} PASSED ({failed} FAILED)")
|
||
print("==================================================")
|
||
|
||
if failed > 0:
|
||
sys.exit(1)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|