From 47508f32fc59fe4c9ce4daccd5806c0916e7e440 Mon Sep 17 00:00:00 2001 From: yasmine_tligui Date: Mon, 14 Jul 2025 11:47:35 +0200 Subject: [PATCH] Functions files --- .gitea/workflows/test.yml | 161 +++++++++++++++ functions/io_utils.py | 10 + {fucntions => functions}/math_utils.py | 0 {fucntions => functions}/string_utils.py | 0 json_to_md.py | 251 +++++++++++++++++++++++ pixi.toml | 17 ++ tests/test_broken_fct.py | 10 + tests/test_io_utils.py | 75 +++++++ tests/test_math_utils.py | 145 +++++++++++++ tests/test_string_utils.py | 47 +++++ 10 files changed, 716 insertions(+) create mode 100644 .gitea/workflows/test.yml create mode 100644 functions/io_utils.py rename {fucntions => functions}/math_utils.py (100%) rename {fucntions => functions}/string_utils.py (100%) create mode 100644 json_to_md.py create mode 100644 pixi.toml create mode 100644 tests/test_broken_fct.py create mode 100644 tests/test_io_utils.py create mode 100644 tests/test_math_utils.py create mode 100644 tests/test_string_utils.py diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 00000000..9b4c0a83 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,161 @@ +name: Run Pytest with Allure and Coverage Reports + +on: + push: + branches: [testing_json] + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Show checked out files + run: | + echo "πŸ“‚ Files in repo root:" + ls -1 | head -n 20 + + - name: Install Pixi & project dependencies + run: | + echo "πŸ”§ Installing Pixi..." + curl -fsSL https://pixi.sh/install.sh | bash > pixi.log 2>&1 || { + echo "❌ Pixi download failed" + cat pixi.log | grep -i 'error\|fatal' + exit 1 + } + + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + export PATH="$HOME/.pixi/bin:$PATH" + + pixi install > pixi-install.log 2>&1 || { + echo "❌ Pixi project dependencies failed" + cat pixi-install.log | grep -i 'error\|fatal' + exit 1 + } + + echo "βœ… Pixi installed" + pixi list + + - name: Install Python test/report tools + run: | + echo "🐍 Setting up Python environment..." + python -m venv venv + source venv/bin/activate + + pip install -U pip > pip.log 2>&1 || { + echo "❌ pip upgrade failed" + cat pip.log | grep -i 'error\|fatal' + exit 1 + } + + pip install pytest pytest-cov coverage pytest-md-report > pip.log 2>&1 || { + echo "❌ Python testing tools install failed" + cat pip.log | grep -i 'error\|fatal' + exit 1 + } + + echo "βœ… Python tools installed" + pip list + + - name: Install Java + run: | + echo "β˜• Installing Java..." + sudo apt update > java.log 2>&1 && sudo apt install -y openjdk-11-jdk >> java.log 2>&1 || { + echo "❌ Java installation failed" + cat java.log | grep -i 'error\|fatal' + exit 1 + } + echo "βœ… Java installed" + java -version + + - name: Install Allure CLI + run: | + echo "πŸ“Š Installing Allure..." + curl -sL https://github.com/allure-framework/allure2/releases/download/2.27.0/allure-2.27.0.tgz | tar -xz > allure.log 2>&1 || { + echo "❌ Allure download failed" + cat allure.log | grep -i 'error\|fatal' + exit 1 + } + + sudo mv allure-2.27.0 /opt/allure + sudo ln -s /opt/allure/bin/allure /usr/local/bin/allure + echo "βœ… Allure installed" + allure --version + + - name: Run tests and generate reports + run: | + echo "πŸš€ Running tests and generating reports..." + source venv/bin/activate + mkdir -p ci-reports/{allure,coverage,markdown} + + echo "πŸ§ͺ Running pytest with coverage and report generation..." + pixi run pytest . \ + --cov=slic \ + --cov-report=xml:ci-reports/coverage/coverage.xml \ + --cov-report=html:ci-reports/coverage/ \ + --alluredir=allure-results \ + --json-report \ + --json-report-file=ci-reports/markdown/pytest-report.json \ + --capture=no > ci-reports/markdown/raw-test-output.log 2>&1 && { + echo "βœ… Pytest completed" + echo "" + echo "πŸ“„ Markdown: ci-reports/markdown/test-report.md" + echo "" + echo "πŸ“¦ JSON: ci-reports/markdown/pytest.json" + } || { + echo "" + echo "⚠️ Some tests failed" + tail -n 20 ci-reports/markdown/raw-test-output.log | grep -i 'error\|fail' || true + } + + echo "" + echo "πŸ“Š Generating Allure report..." + allure generate allure-results -o ci-reports/allure --clean > /dev/null 2>&1 && { + echo "βœ… Allure report generated" + } || { + echo "⚠️ Allure generation failed" + } + + echo "" + echo "πŸ“ˆ Generating coverage summary..." + coverage report --format=markdown > ci-reports/markdown/coverage-summary.md && { + echo "βœ… Coverage summary generated" + } || { + echo "⚠️ Coverage generation failed" + } + + { + echo "" + echo "πŸ“Š Generating tests markdown summary..." + python generate_test_summary.py + python json_to_md.py \ + --input ci-reports/markdown/pytest-report.json \ + --output ci-reports/markdown/TEST-REPORT.md \ + --allure-dir ci-reports/allure/data/test-cases + echo "βœ… Tests markdown generated" + } || { + echo "⚠️ Tests markdown generation failed" + } + + - name: Commit and push reports + run: | + echo "πŸ“€ Committing test & coverage reports to slic.git..." + git config user.name "ci-bot" + git config user.email "ci-bot@example.com" + git add ci-reports/markdown/TEST-REPORT.md + git commit -m "CI: update test and coverage reports" || echo "⚠️ Nothing to commit" + + echo "πŸš€ Pushing to branch 'testing_json' on slic.git..." + git push https://ci-token:${{ secrets.CI_TOKEN }}@gitea.psi.ch/tligui_y/slic.git HEAD:testing_json > push_main.log 2>&1 || { + echo "❌ Push to main repo failed" + cat push_main.log | grep -i 'error\|fatal\|refused' + exit 1 + } + echo "βœ… Reports pushed to https://gitea.psi.ch/tligui_y/slic/ci-reports/" + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} diff --git a/functions/io_utils.py b/functions/io_utils.py new file mode 100644 index 00000000..d2099040 --- /dev/null +++ b/functions/io_utils.py @@ -0,0 +1,10 @@ +def read_file(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + +def write_file(path, content): + with open(path, "w", encoding="utf-8") as f: + f.write(content) + +def cause_io_error(): + raise IOError("Forced IO Error for testing") diff --git a/fucntions/math_utils.py b/functions/math_utils.py similarity index 100% rename from fucntions/math_utils.py rename to functions/math_utils.py diff --git a/fucntions/string_utils.py b/functions/string_utils.py similarity index 100% rename from fucntions/string_utils.py rename to functions/string_utils.py diff --git a/json_to_md.py b/json_to_md.py new file mode 100644 index 00000000..a29a4348 --- /dev/null +++ b/json_to_md.py @@ -0,0 +1,251 @@ +from collections import defaultdict +from datetime import datetime +import json +import argparse +import os +import re + +def stringify(obj): + if obj is None or obj == "": + return "`None`" + if isinstance(obj, list): + return ', '.join(stringify(e) for e in obj) + if isinstance(obj, dict): + return ', '.join(f"`{k}: {stringify(v)}`" for k, v in obj.items()) + return f"`{str(obj)}`" + +def normalize_nodeid(nodeid): + """Convert pytest nodeid to Allure fullName format""" + match = re.match(r"(tests[/\\].+?)\.py::(.+?)(?:\[.*)?$", nodeid) + if match: + file_part = match.group(1).replace("/", ".").replace("\\", ".") + func_part = match.group(2) + return f"{file_part}#{func_part}" + return None + +def write_json_value(f, value, indent=0): + prefix = " " * indent + if isinstance(value, dict): + if not value: + f.write(f"{prefix}{{}}\n") + else: + for k, v in value.items(): + if isinstance(v, (dict, list)): + f.write(f"{prefix}{k}:\n") + write_json_value(f, v, indent + 1) + else: + # valeur simple, on Γ©crit sur la mΓͺme ligne + if v is None: + f.write(f"{prefix}{k}: None\n") + else: + f.write(f"{prefix}{k}: {v}\n") + elif isinstance(value, list): + if not value: + f.write(f"{prefix}[]\n") + else: + for item in value: + write_json_value(f, item, indent) + else: + if value is None: + f.write(f"{prefix}None\n") + else: + f.write(f"{prefix}{value}\n") + + +def load_allure_metadata(allure_test_cases_dir): + allure_data = {} + if not os.path.exists(allure_test_cases_dir): + print(f"❌ Allure document untraceable: {allure_test_cases_dir}") + return allure_data + + print(f"Loading Allure files from: {allure_test_cases_dir}") + for file in os.listdir(allure_test_cases_dir): + if file.endswith(".json"): + path = os.path.join(allure_test_cases_dir, file) + with open(path, 'r', encoding='utf-8') as f: + try: + data = json.load(f) + full_name = data.get("fullName") + if not full_name: + continue + params = data.get("parameters", []) + severity = data.get("extra", {}).get("severity", None) + allure_data[full_name] = { + "parameters": params, + "severity": severity + } + except Exception as e: + print(f"❌ Error in {file}: {e}") + return allure_data + +def json_to_md_nested(json_path, md_path, allure_dir=None): + with open(json_path) as f: + data = json.load(f) + + allure_data = load_allure_metadata(allure_dir) if allure_dir else {} + + with open(md_path, 'w', encoding='utf-8') as f: + f.write(f"# πŸ§ͺ Test Report\n") + f.write(f"*Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n") + + if 'summary' in data: + f.write("## πŸ“‹ Summary\n") + for key, value in data['summary'].items(): + f.write(f"- **{key.capitalize()}**: {stringify(value)}\n") + duration = data.get("duration") + f.write(f"- **Total Duration**: `{duration:.3f}`s\n" if duration else "- **Total Duration**: `None`\n") + f.write("\n") + + if "tests" in data and "summary" in data: + test_counter = 1 + for test in data["tests"]: + test['global_number'] = test_counter + test_counter += 1 + + f.write("## πŸ”Ž Tests\n") + + summary_items = list(data["summary"].items()) + total_index = next((i for i, (k, _) in enumerate(summary_items) if k == "total"), len(summary_items)) + status_order = [k for k, _ in summary_items[:total_index]] + + tests_by_status = defaultdict(list) + for test in data["tests"]: + outcome = test.get("outcome", "unknown") + tests_by_status[outcome].append(test) + + for status in status_order: + if status not in tests_by_status: + continue + + count = len(tests_by_status[status]) + emoji = "βœ…" if status == "passed" else "❌" + status_label = status.capitalize().replace('_', ' ') + f.write(f"
\n{emoji} {status_label} ({count})\n\n") + + grouped = defaultdict(lambda: defaultdict(list)) + for test in tests_by_status[status]: + nodeid = test.get("nodeid", "") + parts = nodeid.split("::") + filename = parts[0].replace("tests\\", "").replace("tests/", "") + funcname = parts[1].split("[")[0] + grouped[filename][funcname].append(test) + + for filename, funcs in grouped.items(): + f.write(f"
\nπŸ“ {filename}\n\n") + for funcname, tests in funcs.items(): + f.write(f"
\nπŸ”§ Function: `{funcname}`\n\n") + for test in sorted(tests, key=lambda x: x['global_number']): + f.write(f"
\n{emoji} #{test['global_number']}\n\n") + + nodeid = test.get("nodeid", "") + f.write(f"- **Status:** {emoji} `{status}`\n") + duration = test.get("call", {}).get("duration") + f.write(f"- **Duration:** `{duration:.6f}` s\n" if duration else "- **Duration:** `None`\n") + + full_name = normalize_nodeid(nodeid) + allure_info = allure_data.get(full_name) + if allure_info: + if allure_info["parameters"]: + f.write("- **Parameters (Allure):**\n") + for param in allure_info["parameters"]: + name = param.get("name") + val = param.get("value") + f.write(f" - `{name}` = `{val}`\n") + f.write("\n") + if allure_info["severity"]: + f.write(f"- **Severity:** `{allure_info['severity']}`\n") + + for phase in ['setup', 'call', 'teardown']: + if phase in test: + f.write(f"\n### πŸ”§ {phase.capitalize()} Phase\n\n") + for field, value in test[phase].items(): + if value is None: + f.write(f"- **{field.capitalize()}:** None\n") + else: + f.write(f"
\nπŸ“Œ {field.capitalize()}\n\n") + f.write("```\n") + write_json_value(f, value) + f.write("```\n") + f.write("
\n\n") + + f.write("
\n\n") + f.write("
\n\n") + f.write("
\n\n") + f.write("
\n\n") + + if "collectors" in data: + f.write("## πŸ“š Collected files\n") + + grouped = defaultdict(list) + for collector in data["collectors"]: + nodeid = collector.get("nodeid", "unknown") + path = nodeid.split("::")[0] + main_folder = path.split("/")[0] if "/" in path else path + grouped[main_folder].append(collector) + + for folder, collectors in grouped.items(): + has_fail = any(c.get("outcome") != "passed" for c in collectors) + folder_emoji = "βœ…" if not has_fail else "❌" + f.write(f"
\n{folder_emoji} {folder} ({len(collectors)} tests)\n\n") + + outputs = [] + for collector in collectors: + outcome = collector.get("outcome", "unknown") + nodeid = collector.get("nodeid", "unknown") + short_node = nodeid.split("[")[0] + results = collector.get("result", []) + if outcome != "passed" and results: + outputs.append(f"### ❌ {short_node}\n```\n" + "\n".join( + f"{k}: {v}" if isinstance(item, dict) else str(item) + for item in results + for k, v in item.items() if isinstance(item, dict) + ) + "\n```") + + if outputs: + f.write("### 🧾 Error or Result Summary\n\n") + for out in outputs: + f.write(out + "\n") + + collectors_sorted = sorted(collectors, key=lambda c: c.get("nodeid", "").split("[")[0]) + for collector in collectors_sorted: + outcome = collector.get("outcome", "unknown") + emoji = "βœ…" if outcome == "passed" else "❌" + nodeid = collector.get("nodeid", "unknown") + short_node = nodeid.split("[")[0] + f.write(f"
\n{emoji} {short_node}\n\n") + f.write(f"- **Outcome:** `{outcome}`\n") + + other_keys = {k: v for k, v in collector.items() if k not in {"nodeid", "outcome"}} + if other_keys: + f.write("- **Details:**\n") + f.write("```\n") + for k, v in other_keys.items(): + f.write(f"{k}:\n") + try: + if v is None: + f.write(" None\n") + else: + write_json_value(f, v, indent=1) + except Exception as e: + f.write(f" \n") + f.write("\n") + f.write("```\n") + else: + f.write("- **Details:** `None`\n") + + f.write("
\n\n") + + f.write("
\n\n") + +def main(): + parser = argparse.ArgumentParser(description="Convert JSON test results to Markdown.") + parser.add_argument("--input", required=True, help="Path to pytest JSON file") + parser.add_argument("--output", required=True, help="Path to output Markdown file") + parser.add_argument("--allure-dir", required=False, help="Directory of Allure test-cases (optional)") + args = parser.parse_args() + + json_to_md_nested(args.input, args.output, args.allure_dir) + print(f"βœ… Report generated at {args.output}") + +if __name__ == "__main__": + main() diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 00000000..412a459e --- /dev/null +++ b/pixi.toml @@ -0,0 +1,17 @@ +[project] +name = "slic" +version = "0.1.0" +channels = ["conda-forge", "paulscherrerinstitute"] +platforms = ["linux-64"] + +[dependencies] +python = "3.8.*" +pytest = "*" +coverage = "*" +pytest-html = "*" +pytest-cov = "*" +allure-pytest = "*" + +[pypi-dependencies] +pytest-md-report = "*" +pytest-json-report = "*" diff --git a/tests/test_broken_fct.py b/tests/test_broken_fct.py new file mode 100644 index 00000000..4981f98b --- /dev/null +++ b/tests/test_broken_fct.py @@ -0,0 +1,10 @@ +def test_valid_1(): + # To see if it's ignored + assert True + +def test_broken( +# syntax error + +def test_valid_2(): + # To see if it's ignored + assert True diff --git a/tests/test_io_utils.py b/tests/test_io_utils.py new file mode 100644 index 00000000..613a1738 --- /dev/null +++ b/tests/test_io_utils.py @@ -0,0 +1,75 @@ +import pytest +import functions.io_utils +import os +import warnings +import io +from unittest import mock + +def test_read_file(tmp_path): + # Write and read file, normal operation + file = tmp_path / "test.txt" + file.write_text("hello") + content = io_utils.read_file(str(file)) + assert content == "hello" + +def test_write_file(tmp_path): + # Write content to file and verify write success + file = tmp_path / "test_write.txt" + io_utils.write_file(str(file), "data") + assert file.read_text() == "data" + +def test_cause_io_error(): + # Raises manual IOError to simulate IO failure + with pytest.raises(IOError): + io_utils.cause_io_error() + +def test_file_not_found(): + # Reading non-existing file raises FileNotFoundError + with pytest.raises(FileNotFoundError): + io_utils.read_file("nonexistent.file") + +def test_permission_error(monkeypatch): + # Patch open to raise PermissionError simulating access denial + def raise_perm_error(*args, **kwargs): + raise PermissionError("Permission denied") + monkeypatch.setattr("builtins.open", raise_perm_error) + with pytest.raises(PermissionError): + io_utils.read_file("anyfile.txt") + +def test_mock_open_error(monkeypatch): + # Mock open() to raise IOError simulating read error + mocked_open = mock.mock_open() + mocked_open.side_effect = IOError("Mocked IOError") + monkeypatch.setattr("builtins.open", mocked_open) + with pytest.raises(IOError): + with open("file.txt", "r") as f: + f.read() + +def test_file_handle_closed_error(): + # Accessing closed file raises ValueError + f = io.StringIO("content") + f.close() + with pytest.raises(ValueError): + f.read() + +def test_os_error(monkeypatch): + # Patch os.remove to raise OSError simulating filesystem error + def raise_os_error(path): + raise OSError("Simulated OSError") + monkeypatch.setattr("os.remove", raise_os_error) + with pytest.raises(OSError): + os.remove("file.txt") + +def test_write_file_readonly(tmp_path): + # Writing to read-only file raises PermissionError + file = tmp_path / "readonly.txt" + file.write_text("data") + os.chmod(file, 0o444) + with pytest.raises(PermissionError): + with open(file, "w") as f: + f.write("new content") + +def test_file_not_found_error(): + # Raises FileNotFoundError when opening a non-existent file + with pytest.raises(FileNotFoundError): + open("no_such_file.txt", "r") diff --git a/tests/test_math_utils.py b/tests/test_math_utils.py new file mode 100644 index 00000000..255bd48e --- /dev/null +++ b/tests/test_math_utils.py @@ -0,0 +1,145 @@ +import pytest +import functions.math_utils +import math +import sys +import importlib +from unittest import mock + +class CustomError(Exception): + pass + +def test_broken(): + # Simulate an invalid syntaxe test + want_the_test_to_fail + +def test_call_missing_function(): + # Accessing a missing function attribute raises AttributeError + with pytest.raises(AttributeError): + getattr(math_utils, "non_existent_function")() + +def test_addition_pass(): + # Test passes: correct addition + assert math_utils.addition(2, 2) == 4 + +def test_addition_fail(): + # Assertion failure: expected incorrect result + assert math_utils.addition(2, 2) == 5 + +def test_division_zero(): + # Expect ZeroDivisionError when dividing by zero + with pytest.raises(ZeroDivisionError): + math_utils.division(1, 0) + +@pytest.mark.xfail(reason="Expected failure") +def test_multiply_xfail(): + # Expected fail test (xfail): incorrect expected multiply result + assert math_utils.multiply(2, 2) == 5 + +def test_runtime_error(): + # Test raises an uncaught RuntimeError + raise RuntimeError("Forced runtime error") + +def test_memory_error(): + # Manually raise MemoryError to simulate out-of-memory condition + with pytest.raises(MemoryError): + raise MemoryError("Simulated memory error") + +def test_timeout_error(): + # Manually raise TimeoutError simulating timeout conditions + with pytest.raises(TimeoutError): + raise TimeoutError("Simulated timeout error") + +def test_recursion_error(): + # Infinite recursion triggers RecursionError + def recursive(): + return recursive() + with pytest.raises(RecursionError): + recursive() + +def test_floating_point_error(): + # Manually raise FloatingPointError (rare in practice) + with pytest.raises(FloatingPointError): + raise FloatingPointError("Simulated floating point error") + +def test_floating_point_overflow(): + # Exponential overflow triggers OverflowError + with pytest.raises(OverflowError): + math.exp(1000) + +def test_value_error(): + # ValueError on invalid integer conversion + with pytest.raises(ValueError): + int("invalid") + +def test_type_error(): + # TypeError when passing wrong argument type to sum + with pytest.raises(TypeError): + sum(5) + +def test_unhandled_exception(): + # Raises generic unhandled Exception + raise Exception("Generic unhandled exception") + +def test_custom_error(): + # Raises user-defined CustomError exception + with pytest.raises(CustomError): + raise CustomError("Custom error simulation") + +def test_stop_iteration_direct(): + # Directly raise StopIteration exception + raise StopIteration() + +def test_generator_exit_direct(): + # Directly raise GeneratorExit exception + raise GeneratorExit() + +def test_keyboard_interrupt_direct(): + # Directly raise KeyboardInterrupt exception + raise KeyboardInterrupt() + +def test_recursion_limit(monkeypatch): + # Lower recursion limit to force RecursionError on deep recursion + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(50) + def recurse(): + return recurse() + with pytest.raises(RecursionError): + recurse() + sys.setrecursionlimit(original_limit) + +def test_malformed_code(): + # SyntaxError when executing malformed Python code + with pytest.raises(SyntaxError): + exec("def bad(:\n pass") + +def test_sys_exit(monkeypatch): + # Simulate SystemExit via patched sys.exit + def fake_exit(code=0): + raise SystemExit(f"Exit with code {code}") + monkeypatch.setattr(sys, "exit", fake_exit) + with pytest.raises(SystemExit): + sys.exit(1) + +def test_broken_function(monkeypatch): + # Simulate broken function raising TypeError + def broken_func(*args, **kwargs): + raise TypeError("Broken function") + monkeypatch.setattr(__name__, "test_broken_function", broken_func) + with pytest.raises(TypeError): + broken_func() + +def test_import_error_patch(monkeypatch): + # Patch import to simulate ImportError on specific module + original_import = __import__ + def fake_import(name, *args, **kwargs): + if name == "fake_module": + raise ImportError("Simulated ImportError") + return original_import(name, *args, **kwargs) + monkeypatch.setattr("builtins.__import__", fake_import) + with pytest.raises(ImportError): + __import__("fake_module") + +def test_module_not_found_error(): + # Raises ModuleNotFoundError (subclass of ImportError) for missing module + with pytest.raises(ModuleNotFoundError): + importlib.import_module("non_existent_module_xyz") diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py new file mode 100644 index 00000000..1903fee4 --- /dev/null +++ b/tests/test_string_utils.py @@ -0,0 +1,47 @@ +import pytest +import functions.string_utils +import warnings + +def test_uppercase_normal(): + # Normal string uppercase conversion + assert string_utils.uppercase("hello") == "HELLO" + +def test_uppercase_type_error(): + # Raises TypeError when input is None (invalid input) + with pytest.raises(TypeError): + string_utils.uppercase(None) + +def test_reverse_string(): + # Tests string reversal correctness + assert string_utils.reverse("abc") == "cba" + +def test_warning_emit(): + # Emits a Python UserWarning, checks warning capture + warnings.warn("Test warning", UserWarning) + +def test_unicode_encode_error(): + # UnicodeEncodeError due to decoding malformed surrogate byte + with pytest.raises(UnicodeEncodeError): + b'\udc80'.decode('utf-8') + +def test_unicode_decode_error(): + # UnicodeDecodeError when decoding invalid byte sequence + with pytest.raises(UnicodeDecodeError): + b'\xff'.decode('utf-8') + +def test_unicode_decode_surrogateescape(): + # UnicodeDecodeError with strict error handler on invalid byte + with pytest.raises(UnicodeDecodeError): + b"\x80".decode("utf-8", errors="strict") + +def test_import_warning(): + # Capture and check ImportWarning emitted + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warnings.warn("Import warning", ImportWarning) + assert any(item.category == ImportWarning for item in w) + +@pytest.mark.xfail(reason="Expected failure: uppercase does not handle digits") +def test_xfail_uppercase_digits(): + # Expected fail test because uppercase won't change digits + assert string_utils.uppercase("abc123") == "ABC1234"