diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5b1348 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [pipfile-experiment] + pull_request: + branches: [pipfile-experiment] + +jobs: + test: + runs-on: ubuntu-latest + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_ROOT_USER_ACTION: "ignore" + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies and pytest + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pytest + + build: + runs-on: ubuntu-latest + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_ROOT_USER_ACTION: "ignore" + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m build diff --git a/.gitignore b/.gitignore index 2f24a10..07737f4 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Project proposal file +Project_3_proposal.docx \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..c03e589 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +minecraft-textcraft = {extras = ["dev"], file = ".", editable = true} + +[dev-packages] +pytest = "*" +build = "*" + +[requires] +python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..6840115 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,84 @@ +{ + "_meta": { + "hash": { + "sha256": "b34062ed12fc62058d550d1a7b5a9751dc715e4a1d210caea31ec1e1d46d22bf" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "minecraft-textcraft": { + "editable": true, + "extras": [ + "dev" + ], + "file": "." + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 6022e0e..b246700 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ -# Python Package Exercise +[![CI](https://github.com/swe-students-fall2025/3-python-package-brio/actions/workflows/ci.yml/badge.svg)](https://github.com/swe-students-fall2025/3-python-package-brio/actions/workflows/ci.yml) +# minecraft-textcraft + +A Python package that converts text into blocky Minecraft-style ASCII art, with support for special inline commands like \sword, \heart, and \diamond. Commands are center-aligned with text for a professional, visually appealing output. + +# Quickstart + +To setup: +```bash +git clone git@github.com:swe-students-fall2025/3-python-package-brio.git +cd 3-python-package-brio +pipenv shell +pipenv install -e ".[dev]" +``` + +To build: +```bash +pipenv run python -m build +``` +Artifacts will be in `dist/`. + +To run: +```bash +pipenv run minecraft-textcraft +``` + +To run tests: +```bash +pipenv run pytest +``` -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..061c9b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "minecraft-textcraft" +version = "0.0.2" +description = "A Python package that converts text into blocky Minecraft-style ASCII art..." +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "Intended Audience :: Education", + "Operating System :: OS Independent", +] +[project.optional-dependencies] +dev = ["pytest", "build"] +[project.scripts] +minecraft-textcraft = "minecraft_textcraft.__main__:main" diff --git a/src/minecraft_textcraft/__init__.py b/src/minecraft_textcraft/__init__.py new file mode 100644 index 0000000..532254c --- /dev/null +++ b/src/minecraft_textcraft/__init__.py @@ -0,0 +1,7 @@ +__version__ = "0.0.1" + +from .add import add +from .colorize_ascii import colorize_ascii, Color +from .commands import getCommand, getCommandByName, listCategories, listCommands +from .animate import animate_typewriter, animate_scroll, animate_wave, play_animation +from .render import render, renderTextOnly, renderCommandOnly, renderTextAndCommands diff --git a/src/minecraft_textcraft/__main__.py b/src/minecraft_textcraft/__main__.py new file mode 100644 index 0000000..3d0164e --- /dev/null +++ b/src/minecraft_textcraft/__main__.py @@ -0,0 +1,44 @@ +from .colorize_ascii import colorize_ascii, Color +try: + from .animate import animate_typewriter, animate_scroll, animate_wave, play_animation + _ANIM_AVAILABLE = True +except Exception: + _ANIM_AVAILABLE = False + +import argparse +HELLO = """ +█ █ █████ █ █ █████ +█ █ █ █ █ █ █ +█████ ████ █ █ █ █ +█ █ █ █ █ █ █ +█ █ █████ █████ █████ █████ +""" + + +def main(): + p = argparse.ArgumentParser(add_help=False) + p.add_argument("--effect", choices=["type", "scroll", "wave"]) + p.add_argument("--fps", type=int, default=12) + p.add_argument("--color", default="RED") + args, _ = p.parse_known_args() + + if not args.effect or not _ANIM_AVAILABLE: + # 保持你原来的输出 + print(colorize_ascii(HELLO.strip("\n"), Color.RED)) + return + + # 有 --effect 时才播放动画 + color = Color.from_string(args.color) + art = HELLO.strip("\n") + if args.effect == "type": + frames = animate_typewriter(art, color=color, cps=12) + elif args.effect == "scroll": + frames = animate_scroll(art, color=color, cols_per_frame=2, loops=2) + else: # wave + frames = animate_wave(art, color=color, amplitude=2, period_cols=10, frames=36) + play_animation(frames, fps=args.fps) + + + +if __name__ == "__main__": + main() diff --git a/src/minecraft_textcraft/add.py b/src/minecraft_textcraft/add.py new file mode 100644 index 0000000..e1829c3 --- /dev/null +++ b/src/minecraft_textcraft/add.py @@ -0,0 +1,2 @@ +def add(a: int, b: int) -> int: + return a + b diff --git a/src/minecraft_textcraft/animate.py b/src/minecraft_textcraft/animate.py new file mode 100644 index 0000000..3e7a33e --- /dev/null +++ b/src/minecraft_textcraft/animate.py @@ -0,0 +1,98 @@ +# src/minecraft_textcraft/animate.py +from typing import Generator, Iterable, Optional +from .colorize_ascii import colorize_ascii, Color + +def _normalize(ascii_art: str) -> list[str]: + lines = ascii_art.splitlines() + if not lines: + return [] + width = max(len(ln) for ln in lines) + return [ln.ljust(width) for ln in lines] + +def animate_typewriter( + ascii_art: str, + color: Optional[Color] = None, + cps: int = 10, # characters per second +) -> Generator[str, None, None]: + lines = _normalize(ascii_art) + if not lines: + yield "" + return + width = len(lines[0]) + # speed: 12 fps + step = max(1, int(round(cps / 12))) + shown = 0 + while shown <= width: + frame = "\n".join(ln[:shown] for ln in lines) + yield colorize_ascii(frame, color) if color else frame + shown += step + full = "\n".join(lines) + yield colorize_ascii(full, color) if color else full + +def animate_scroll( + ascii_art: str, + color: Optional[Color] = None, + cols_per_frame: int = 1, + loops: int = 1, +) -> Generator[str, None, None]: + lines = _normalize(ascii_art) + if not lines: + yield "" + return + cols_per_frame = max(1, int(cols_per_frame)) + width = len(lines[0]) + pad = " " * cols_per_frame + padded = [pad + ln + pad for ln in lines] # width = width + 2*cols + total_w = width + 2 * cols_per_frame + + def colorize_if_needed(s: str) -> str: + return colorize_ascii(s, color) if color else s + + def one_loop() -> Generator[str, None, None]: + for start in range(0, total_w): + frame_cols = [row[start:start + cols_per_frame] for row in padded] + yield colorize_if_needed("\n".join(frame_cols)) + + loops = max(1, int(loops)) + for _ in range(loops): + yield from one_loop() + + +def animate_wave( + ascii_art: str, + color: Optional[Color] = None, + amplitude: int = 2, + period_cols: int = 10, + frames: int = 24, +) -> Generator[str, None, None]: + + import math + base = _normalize(ascii_art) + if not base: + yield "" + return + h = len(base) + w = len(base[0]) + pad = amplitude + 1 + for t in range(max(1, frames)): + canvas_h = h + 2 * pad + canvas = [[" "] * w for _ in range(canvas_h)] + for r in range(h): + for c, ch in enumerate(base[r]): + phase = 2 * math.pi * (c / max(1, period_cols)) + 2 * math.pi * (t / max(1, frames)) + offset = int(round(amplitude * math.sin(phase))) + rr = r + pad + offset + if 0 <= rr < canvas_h: + canvas[rr][c] = ch + frame = "\n".join("".join(row) for row in canvas) + yield colorize_ascii(frame, color) if color else frame + +def play_animation(frames: Iterable[str], fps: int = 12) -> None: + import time + if fps <= 0: + fps = 12 + delay = 1.0 / fps + for f in frames: + print("\033[2J\033[H", end="") # clear screen + cursor home + print(f) + time.sleep(delay) diff --git a/src/minecraft_textcraft/colorize_ascii.py b/src/minecraft_textcraft/colorize_ascii.py new file mode 100644 index 0000000..e982b26 --- /dev/null +++ b/src/minecraft_textcraft/colorize_ascii.py @@ -0,0 +1,24 @@ +from enum import Enum + +ESC = "\x1b" + +class Color(Enum): + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + DEFAULT = 0 + + def __str__(self): + return f"{ESC}[{self.value}m" + + @classmethod + def from_string(cls, s: str) -> "Color": + try: + return cls[s.upper()] + except KeyError: + return cls.DEFAULT + + +def colorize_ascii(ascii: str, color: Color): + return f"{color}{ascii}{Color.DEFAULT}" diff --git a/src/minecraft_textcraft/commands.py b/src/minecraft_textcraft/commands.py new file mode 100644 index 0000000..383c59e --- /dev/null +++ b/src/minecraft_textcraft/commands.py @@ -0,0 +1,42 @@ +from .sample_commands import SAMPLE_COMMANDS + +# Use sample commands for now,can be replaced later +COMMANDS = SAMPLE_COMMANDS + + +def getCommand(category: str = "weapons") -> dict[str, str]: + if category not in COMMANDS: + available_categories = list(COMMANDS.keys()) + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + return COMMANDS[category].copy() + + +def getCommandByName(category: str, command_name: str) -> str: + if category not in COMMANDS: + available_categories = list(COMMANDS.keys()) + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + if command_name not in COMMANDS[category]: + available_commands = list(COMMANDS[category].keys()) + raise KeyError(f"Command '{command_name}' not found in category '{category}'. Available commands: {available_commands}") + + return COMMANDS[category][command_name] + + +def listCategories() -> list[str]: + return list(COMMANDS.keys()) + + +def listCommands(category: str = None) -> list[str]: + if category is None: + all_commands = [] + for cat_commands in COMMANDS.values(): + all_commands.extend(cat_commands.keys()) + return all_commands + + if category not in COMMANDS: + available_categories = list(COMMANDS.keys()) + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + return list(COMMANDS[category].keys()) diff --git a/src/minecraft_textcraft/font_data.py b/src/minecraft_textcraft/font_data.py new file mode 100644 index 0000000..24e2b16 --- /dev/null +++ b/src/minecraft_textcraft/font_data.py @@ -0,0 +1,724 @@ +""" +Font data for 16-block tall Minecraft-style ASCII characters. +Each character is represented as a list of strings, one per row. +Characters use block characters (█) and spaces for the blocky look. +""" + +# Height constant - all characters must be this height +CHAR_HEIGHT = 16 + +# Font data: dictionary mapping character to list of rows +FONT_DATA = { + "A": [ + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + ], + "B": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ", + " ", + ], + "C": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "D": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ", + " ", + ], + "E": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "F": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "G": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "H": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "I": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "J": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ", + " ", + ], + "K": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "L": [ + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "M": [ + " ██ ██ ", + " ████ ████ ", + " ██ ██ ██ ██ ", + " ██ ████ ██ ", + " ██ ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "N": [ + " ██ ██ ", + " ████ ██ ", + " ██ ██ ██ ", + " ██ ██ ██ ", + " ██ ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "O": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "P": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "Q": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ██", + " ██ ██ ██", + " ██ ████", + " ██ ██ ", + " ███████ ██", + " ", + " ", + ], + "R": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "S": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██", + " ██", + " ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "T": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "U": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██████ ", + " ", + " ", + ], + "V": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "W": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ██", + " ██ ████ ██", + " ██ ██ ██ ██", + " ██ ██ ██ ██", + " ████ ████ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "X": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "Y": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "Z": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "0": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + ], + "1": [ + " ██ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██████████ ", + " ", + " ", + ], + "2": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "3": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "4": [ + " ██ ", + " ███ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "5": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ███████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "6": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ███████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "7": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "8": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "9": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + " ": [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], +} + + +# Fixed width for all characters - must match the widest character +CHAR_WIDTH = 15 # Maximum width found in font data + + +def get_char_width(char: str) -> int: + """Get the width of a character in blocks (fixed width).""" + return CHAR_WIDTH + + +def get_char_height() -> int: + """Get the height of characters in blocks (constant).""" + return CHAR_HEIGHT + + +def get_char(char: str) -> list[str]: + """ + Get the font data for a character, padded to fixed width. + + Each character is padded to CHAR_WIDTH (15) to ensure proper alignment + when combining characters horizontally. + + Args: + char: Single character (A-Z, 0-9, or space) + + Returns: + List of strings representing the character rows, each padded to CHAR_WIDTH + + Raises: + KeyError: If character is not in the font data + """ + if char.upper() not in FONT_DATA: + raise KeyError( + f"Character '{char}' is not supported. Only A-Z, 0-9, and space are allowed." + ) + + char_rows = FONT_DATA[char.upper()] + # Pad each row to fixed width (CHAR_WIDTH) to ensure alignment + padded_rows = [] + for row in char_rows: + # Pad each row to exactly CHAR_WIDTH characters + padded_row = row.ljust(CHAR_WIDTH) + padded_rows.append(padded_row) + + return padded_rows diff --git a/src/minecraft_textcraft/render.py b/src/minecraft_textcraft/render.py new file mode 100644 index 0000000..8ff9d2e --- /dev/null +++ b/src/minecraft_textcraft/render.py @@ -0,0 +1,399 @@ +""" +Render functions for converting text into Minecraft-style ASCII art. +Supports A-Z, 0-9 text and inline commands. +""" + +import re +from typing import List, Tuple +from .font_data import get_char, get_char_height, CHAR_HEIGHT, CHAR_WIDTH +from .commands import getCommandByName, listCommands + + +def _validate_text(text: str) -> bool: + """ + Validate that text contains only A-Z, 0-9, and spaces. + + Args: + text: Text to validate + + Returns: + True if valid, False otherwise + """ + return bool(re.match(r"^[A-Za-z0-9\s]+$", text)) + + +def _parse_input(input_str: str) -> Tuple[List[str], List[str]]: + """ + Parse input string into text parts and command parts. + + Commands are specified with double backslash: \\command + + Args: + input_str: Input string to parse + + Returns: + Tuple of (text_parts, command_parts) + text_parts: List of text segments + command_parts: List of command names (without backslashes) + """ + # Split by double backslash pattern + parts = re.split(r"(\\\\\w+)", input_str) + + text_parts = [] + command_parts = [] + + for part in parts: + if not part: + continue + if part.startswith("\\\\"): + # This is a command + command_name = part[2:] # Remove double backslash + command_parts.append(command_name) + text_parts.append(None) # Placeholder for command position + else: + # This is text + text_parts.append(part) + command_parts.append(None) + + return text_parts, command_parts + + +def renderTextOnly(text: str) -> str: + """ + Render text only (A-Z, 0-9) into 16-block tall Minecraft-style ASCII art. + + Args: + text: Text to render (only A-Z, 0-9, spaces allowed) + + Returns: + Multi-line string representing the rendered text + + Raises: + ValueError: If text contains invalid characters + + Edge Cases Handled: + - Empty string → returns empty string + - Whitespace-only string → returns empty string (no visible output) + - Invalid characters → raises ValueError with details + """ + # Edge case: empty string + if not text: + return "" + + # Edge case: whitespace-only string + if not text.strip(): + return "" + + if not _validate_text(text): + invalid_chars = set(text) - set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 " + ) + raise ValueError( + f"Text contains invalid characters. Only A-Z, 0-9, and spaces are allowed. Invalid: {invalid_chars}" + ) + + # Convert to uppercase for rendering + text = text.upper() + + # Get character data for each character + char_data_list = [] + for char in text: + try: + char_data = get_char(char) + char_data_list.append(char_data) + except KeyError as e: + # Edge case: character not in font (shouldn't happen after validation, but defensive) + raise ValueError(f"Character '{char}' cannot be rendered: {e}") + + # Edge case: no valid characters (shouldn't happen, but defensive) + if not char_data_list: + return "" + + # All characters have the same height (16) + height = get_char_height() + result_lines = [] + + # Spacing between characters (6 spaces for better readability) + CHAR_SPACING = 6 + + for row in range(height): + line = "" + for i, char_data in enumerate(char_data_list): + # Edge case: ensure we don't go out of bounds + if row < len(char_data): + line += char_data[row] + else: + # If somehow row is out of bounds, add spaces + # Use CHAR_WIDTH since get_char() always returns padded rows + line += " " * CHAR_WIDTH + + # Add spacing between characters (except after the last character) + if i < len(char_data_list) - 1: + line += " " * CHAR_SPACING + + result_lines.append(line) + + return "\n".join(result_lines) + + +def renderCommandOnly(command_name: str) -> str: + """ + Render a single command only, padded to its natural width box. + + Each command is padded to its own maximum width so all rows align properly + within that command's box. Different commands can have different box sizes. + For example: sword might be 20 chars wide, earth might be 45 chars wide. + + Args: + command_name: Name of the command (e.g., "sword", "heart") + + Returns: + Multi-line string representing the rendered command, + with each row padded to the command's maximum width + """ + # Search through all categories to find the command + all_commands = listCommands() + + if command_name not in all_commands: + available = ", ".join(all_commands) + raise ValueError( + f"Command '{command_name}' not found. Available commands: {available}" + ) + + # Find which category contains this command + from .sample_commands import SAMPLE_COMMANDS + + for category, commands in SAMPLE_COMMANDS.items(): + if command_name in commands: + command_art = getCommandByName(category, command_name) + + # Split command into lines + lines = command_art.split("\n") + if not lines: + return "" + + # Find the maximum width for THIS specific command + # Filter out empty/whitespace-only lines to find the actual content width + non_empty_lines = [line for line in lines if line.strip()] + + # Edge case: if command has no content, return empty + if not non_empty_lines: + return "" + + max_width = max(len(line) for line in non_empty_lines) + + # Pad each row to this command's maximum width (creates a box for this command) + padded_lines = [] + for line in lines: + # Pad each line to the command's natural width + padded_line = line.ljust(max_width) + padded_lines.append(padded_line) + + return "\n".join(padded_lines) + + raise ValueError(f"Command '{command_name}' not found in any category") + + +def renderTextAndCommands(text: str) -> str: + """ + Render text with inline commands mixed in. + Commands are specified with double backslash: \\command + + Args: + text: Text with inline commands (e.g., "HELLO \\heart WORLD") + + Returns: + Multi-line string representing the rendered text and commands + + Raises: + ValueError: If malformed commands are detected (e.g., \\\\ without command name) + """ + if not text: + return "" + + # Parse the input + parts = [] + current_text = "" + i = 0 + + while i < len(text): + # Check for double backslash + if i < len(text) - 1 and text[i] == "\\" and text[i + 1] == "\\": + # Found start of command + if current_text: + parts.append(("text", current_text)) + current_text = "" + + # Find the command name + i += 2 # Skip \\ + command_start = i + while i < len(text) and (text[i].isalnum() or text[i] == "_"): + i += 1 + command_name = text[command_start:i] + + # Edge case: malformed command (\\ followed by nothing or invalid characters) + if not command_name: + raise ValueError( + f"Malformed command detected at position {i-2}. " + "Commands must be in the format: \\\\commandname (e.g., \\\\sword)" + ) + + parts.append(("command", command_name)) + # Don't increment i here, it's already at the right position + else: + current_text += text[i] + i += 1 + + if current_text: + parts.append(("text", current_text)) + + # Edge case: no valid parts to render + if not parts: + return "" + + # Render each part + rendered_parts = [] + for part_type, part_content in parts: + if part_type == "text": + # Only render if there's actual non-whitespace text + if part_content and part_content.strip(): + rendered = renderTextOnly(part_content) + rendered_parts.append(rendered) + # Skip whitespace-only text parts + elif part_type == "command": + rendered = renderCommandOnly(part_content) + rendered_parts.append(rendered) + + # Edge case: all parts were empty/whitespace + if not rendered_parts: + return "" + + # Combine parts horizontally with proper alignment + return _combine_horizontally(rendered_parts) + + +def _combine_horizontally(parts: List[str]) -> str: + """ + Combine multiple rendered parts horizontally, center-aligning them vertically. + + Args: + parts: List of rendered ASCII art strings + + Returns: + Combined multi-line string + """ + if not parts: + return "" + + # Filter out empty parts + non_empty_parts = [p for p in parts if p and p.strip()] + if not non_empty_parts: + return "" + + # Split each part into lines + part_lines = [] + max_height = CHAR_HEIGHT + + for part in non_empty_parts: + # Split into lines, preserving all lines (including trailing empty ones) + lines = part.split("\n") + # Remove trailing empty lines but keep leading/middle empty lines + while lines and not lines[-1].strip(): + lines.pop() + if not lines: + continue + part_lines.append(lines) + max_height = max(max_height, len(lines)) + + # Edge case: all parts were empty after splitting + if not part_lines: + return "" + + # Pad all parts to max_height by adding empty lines at top/bottom for center alignment + padded_parts = [] + for lines in part_lines: + height = len(lines) + if height < max_height: + # Center align: add half padding above and below + pad_top = (max_height - height) // 2 + pad_bottom = max_height - height - pad_top + + # Get width of lines for padding - use max width to handle any variations + # Each part should already have consistent width, but we use max for safety + width = max(len(line) for line in lines) if lines else 0 + if width == 0: + continue # Skip parts with no width + padded = [" " * width] * pad_top + lines + [" " * width] * pad_bottom + padded_parts.append(padded) + else: + padded_parts.append(lines) + + # Spacing between commands (4 spaces) + COMMAND_SPACING = 4 + + # Combine horizontally + result_lines = [] + for row in range(max_height): + line = "" + for i, part in enumerate(padded_parts): + if row < len(part): + line += part[row] + else: + # Edge case: Add spaces if this part is shorter than expected + if part: + width = max(len(p) for p in part) if part else 0 + line += " " * width + + # Add spacing between commands (except after the last command) + if i < len(padded_parts) - 1: + line += " " * COMMAND_SPACING + + result_lines.append(line) + + return "\n".join(result_lines) + + +def render(input_str: str) -> str: + """ + Main render function that routes input to appropriate sub-function. + + Automatically detects if input contains: + - Text only → renderTextOnly() + - Commands only → renderCommandOnly() or handles multiple commands + - Text and commands → renderTextAndCommands() + + Args: + input_str: Input string with optional commands (\\command format) + + Returns: + Multi-line string representing the rendered output + """ + if not input_str: + return "" + + # Check if input contains double backslash (commands) + has_commands = "\\\\" in input_str + + # Check if input contains text (A-Z, 0-9) + text_only_part = re.sub(r"\\\\\w+", "", input_str).strip() + has_text = bool(text_only_part) and bool(re.search(r"[A-Za-z0-9]", text_only_part)) + + # Route to appropriate function + if has_text and has_commands: + # Mixed text and commands + return renderTextAndCommands(input_str) + elif has_commands: + # Commands only - extract all commands + commands = re.findall(r"\\\\(\w+)", input_str) + if len(commands) == 1: + return renderCommandOnly(commands[0]) + else: + # Multiple commands only - combine them + parts = [] + for cmd in commands: + rendered = renderCommandOnly(cmd) + parts.append(rendered) + return _combine_horizontally(parts) + else: + # Text only + return renderTextOnly(input_str) diff --git a/src/minecraft_textcraft/sample_commands.py b/src/minecraft_textcraft/sample_commands.py new file mode 100644 index 0000000..f6f5672 --- /dev/null +++ b/src/minecraft_textcraft/sample_commands.py @@ -0,0 +1,49 @@ +SAMPLE_COMMANDS = { + "weapons": { + "sword": """ () + )( + o======o + || + || + || + || + || + || + || + || + || + || + \\/ + + """, + }, + "nature": { + "earth": ( + " _-o#&&*''''?d:>b\\_\n" + " _o/\"`'' '',, dMF9MMMMMHo_\n" + " .o&#' `\"MbHMMMMMMMMMMMHo.\n" + ' .o"" \' vodM*$&&HMMMMMMMMMM?.\n' + " ,' $M&ood,~'`(&##MMMMMMH\\\n" + " / ,MMMMMMM#b?#bobMMMMHMMML\n" + " & ?MMMMMMMMMMMMMMMMM7MMM$R*Hk\n" + " ?$. :MMMMMMMMMMMMMMMMMMM/HMMM|`*L\n" + "| |MMMMMMMMMMMMMMMMMMMMbMH' T,\n" + "$H#: `*MMMMMMMMMMMMMMMMMMMMb#}' `?\n" + ']MMH# ""*""""*#MMMMMMMMMMMMM\' -\n' + "MMMMMb_ |MMMMMMMMMMMP' :\n" + "HMMMMMMMHo `MMMMMMMMMT .\n" + "?MMMMMMMMP 9MMMMMMMM} -\n" + "-?MMMMMMM |MMMMMMMMM?,d- '\n" + " :|MMMMMM- `MMMMMMMT .M|. :\n" + " .9MMM[ &MMMMM*' `' .\n" + ' :9MMk `MMM#" -\n' + " &M} ` .-\n" + " `&. .\n" + " `~, . ./\n" + " . _ .-\n" + " '`--._,dd###pp=\"\"'\n" + " \n" + " " + ), + }, +} diff --git a/test_render_standalone.py b/test_render_standalone.py new file mode 100644 index 0000000..eaee8d0 --- /dev/null +++ b/test_render_standalone.py @@ -0,0 +1,327 @@ +""" +Standalone test script that doesn't require pipenv. +Run with: python test_render_standalone.py +Make sure you're in the 3-python-package-brio directory. +""" + +import sys +import os + +# Add the src directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from minecraft_textcraft import ( + render, + renderTextOnly, + renderCommandOnly, + renderTextAndCommands, +) + + +def print_test_header(test_num, description): + """Print a formatted test header""" + print("=" * 70) + print(f"TEST {test_num}: {description}") + print("=" * 70) + print() + + +def print_input_output(input_text, output_text, input_type="Text"): + """Print what we're testing and the result""" + print(f"📥 {input_type}: {repr(input_text)}") + print() + print("📤 Output:") + print("-" * 70) + print(output_text) + print("-" * 70) + print() + + +def test_render_text_only(): + """Test rendering text only""" + print_test_header(1, "Render Text Only") + input_text = "HELLO" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_numbers(): + """Test rendering numbers""" + print_test_header(2, "Render Numbers") + input_text = "12345" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_command_only_sword(): + """Test rendering sword command""" + print_test_header(3, "Render Command Only - Sword") + command_name = "sword" + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Command") + print() + + +def test_render_command_only_earth(): + """Test rendering earth command""" + print_test_header(4, "Render Command Only - Earth") + command_name = "earth" + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Command") + print() + + +def test_render_mixed_text_and_sword(): + """Test rendering text with sword command""" + print_test_header(5, "Render Mixed Text and Commands - Sword") + input_text = "GET \\\\sword NOW" + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Mixed Text") + print() + + +def test_render_mixed_text_and_earth(): + """Test rendering text with earth command""" + print_test_header(6, "Render Mixed Text and Commands - Earth") + input_text = "HELLO \\\\earth WORLD" + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Mixed Text") + print() + + +def test_render_main_text_only(): + """Test the main render function with text only""" + print_test_header(7, "Main Render Function - Text Only") + input_text = "MINECRAFT" + result = render(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_main_command_only(): + """Test the main render function with command only""" + print_test_header(8, "Main Render Function - Command Only (Sword)") + input_text = "\\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Command") + print() + + +def test_render_main_mixed(): + """Test the main render function with mixed input""" + print_test_header(9, "Main Render Function - Mixed (Text + Sword)") + input_text = "GET \\\\sword NOW" + result = render(input_text) + print_input_output(input_text, result, "Mixed") + print() + + +def test_render_main_multiple_commands(): + """Test the main render function with multiple commands""" + print_test_header(10, "Main Render Function - Multiple Commands") + input_text = "\\\\sword \\\\earth \\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands") + print() + + +def test_edge_case_empty_string(): + """Test edge case: empty string""" + print_test_header(11, "Edge Case - Empty String") + input_text = "" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Empty String") + print() + + +def test_edge_case_whitespace_only(): + """Test edge case: whitespace only""" + print_test_header(12, "Edge Case - Whitespace Only") + input_text = " " + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Whitespace Only") + print() + + +def test_edge_case_invalid_characters(): + """Test edge case: invalid characters (should raise error)""" + print_test_header(13, "Edge Case - Invalid Characters") + input_text = "HELLO!" + try: + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Invalid Characters") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Input: {repr(input_text)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_edge_case_invalid_command(): + """Test edge case: invalid command (should raise error)""" + print_test_header(14, "Edge Case - Invalid Command") + command_name = "invalid_command" + try: + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Invalid Command") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Command: {repr(command_name)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_edge_case_malformed_command(): + """Test edge case: malformed command (should raise error)""" + print_test_header(15, "Edge Case - Malformed Command") + input_text = "HELLO \\\\ WORLD" + try: + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Malformed Command") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Input: {repr(input_text)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_text_with_spaces(): + """Test text with spaces""" + print_test_header(16, "Text with Spaces") + input_text = "HELLO WORLD" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text with Spaces") + print() + + +def test_mixed_uppercase_lowercase(): + """Test mixed case (should be converted to uppercase)""" + print_test_header(17, "Mixed Case Text (Auto-uppercase)") + input_text = "Hello World 123" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Mixed Case") + print() + + +def test_multiple_commands_only(): + """Test multiple commands without text""" + print_test_header(18, "Multiple Commands Only") + input_text = "\\\\sword \\\\earth" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands") + print() + + +def test_commands_at_start(): + """Test command at the start of text""" + print_test_header(19, "Command at Start") + input_text = "\\\\sword HELLO" + result = render(input_text) + print_input_output(input_text, result, "Command at Start") + print() + + +def test_commands_at_end(): + """Test command at the end of text""" + print_test_header(20, "Command at End") + input_text = "HELLO \\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Command at End") + print() + + +def test_multiple_commands_with_text(): + """Test multiple commands interspersed with text""" + print_test_header(21, "Multiple Commands with Text") + input_text = "GET \\\\sword AND \\\\earth NOW" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands with Text") + print() + + +def test_consecutive_commands(): + """Test consecutive commands""" + print_test_header(22, "Consecutive Commands") + input_text = "\\\\sword\\\\earth\\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Consecutive Commands") + print() + + +def test_single_character(): + """Test single character rendering""" + print_test_header(23, "Single Character") + input_text = "A" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Single Character") + print() + + +def test_single_digit(): + """Test single digit rendering""" + print_test_header(24, "Single Digit") + input_text = "5" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Single Digit") + print() + + +def test_long_text(): + """Test long text string""" + print_test_header(25, "Long Text String") + input_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Long Text") + print() + + +def test_text_with_only_spaces(): + """Test text with only spaces between characters""" + print_test_header(26, "Text with Only Spaces") + input_text = "A B C D" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text with Spaces") + print() + + +if __name__ == "__main__": + try: + test_render_text_only() + test_render_numbers() + test_render_command_only_sword() + test_render_command_only_earth() + test_render_mixed_text_and_sword() + test_render_mixed_text_and_earth() + test_render_main_text_only() + test_render_main_command_only() + test_render_main_mixed() + test_render_main_multiple_commands() + test_edge_case_empty_string() + test_edge_case_whitespace_only() + test_edge_case_invalid_characters() + test_edge_case_invalid_command() + test_edge_case_malformed_command() + test_text_with_spaces() + test_mixed_uppercase_lowercase() + test_multiple_commands_only() + test_commands_at_start() + test_commands_at_end() + test_multiple_commands_with_text() + test_consecutive_commands() + test_single_character() + test_single_digit() + test_long_text() + test_text_with_only_spaces() + + print("=" * 70) + print("✅ All tests completed successfully!") + print("=" * 70) + except Exception as e: + print("=" * 70) + print(f"❌ ERROR: {e}") + print("=" * 70) + import traceback + + traceback.print_exc() diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..024ab10 --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,4 @@ +from minecraft_textcraft import add + +def test_add(): + assert add(1, 1) == 2 diff --git a/tests/test_animate.py b/tests/test_animate.py new file mode 100644 index 0000000..69280c8 --- /dev/null +++ b/tests/test_animate.py @@ -0,0 +1,26 @@ +# tests/test_animate.py +import itertools +from minecraft_textcraft.animate import animate_typewriter, animate_scroll, animate_wave +from minecraft_textcraft.colorize_ascii import Color + +ASCII = "AB\nCD" + +def take(n, it): + return list(itertools.islice(it, n)) + +def test_typewriter_progresses_and_finishes(): + frames = list(animate_typewriter(ASCII, color=Color.GREEN, cps=24)) + assert len(frames) >= 2 + assert frames[0] + assert frames[-1].count("\n") in (1, 2, 3) + assert "A" in frames[-1] and "D" in frames[-1] + +def test_scroll_changes_over_time(): + frames = take(6, animate_scroll(ASCII, cols_per_frame=2, loops=1)) + assert len(frames) == 6 + assert len(set(frames)) > 1 + +def test_wave_produces_fixed_frames(): + frames = list(animate_wave(ASCII, amplitude=1, period_cols=4, frames=8)) + assert len(frames) == 8 + assert len(set(frames)) > 1 diff --git a/tests/test_colorize_ascii.py b/tests/test_colorize_ascii.py new file mode 100644 index 0000000..427742d --- /dev/null +++ b/tests/test_colorize_ascii.py @@ -0,0 +1,14 @@ +import pytest +from minecraft_textcraft import colorize_ascii, Color + +ESC = "\x1b" + + +@pytest.mark.parametrize("color", list(Color)) +def test_colorize_outputs_correct_ansi_sequence(color): + s = colorize_ascii("Hello", color) + expected_prefix = f"{ESC}[{color.value}m" + expected_suffix = f"{ESC}[0m" + assert s.startswith(expected_prefix) + assert s.endswith(expected_suffix) + assert str(color.value) in s diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..541269c --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,51 @@ +import pytest +from minecraft_textcraft import getCommand, getCommandByName, listCategories, listCommands + + +def test_getCommand(): + weapons = getCommand("weapons") + assert "sword" in weapons + # assert "bow" in weapons + # assert "shield" in weapons + + +def test_getCommand_default(): + weapons = getCommand() + assert "sword" in weapons + + +def test_getCommandByName(): + sword_art = getCommandByName("weapons", "sword") + assert isinstance(sword_art, str) + assert len(sword_art) > 0 + + +def test_getCommandByName_invalid_category(): + with pytest.raises(KeyError): + getCommandByName("invalid", "sword") + + +def test_getCommandByName_invalid_command(): + with pytest.raises(KeyError): + getCommandByName("weapons", "invalid") + + +def test_listCategories(): + categories = listCategories() + assert "weapons" in categories + # assert "items" in categories + # assert "tools" in categories + assert "nature" in categories + + +def test_listCommands(): + all_commands = listCommands() + assert "sword" in all_commands + # assert "heart" in all_commands + + +def test_listCommands_by_category(): + weapons = listCommands("weapons") + assert "sword" in weapons + # assert "bow" in weapons + # assert "shield" in weapons