diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294675e..aed832d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,19 @@ name: CI Pipeline on: push: - branches: - - master + branches: [master, main, develop] pull_request: - branches: - - master + branches: [master, main, develop] + +# Cancel in-flight runs for the same branch on new pushes +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build-and-test: + # ─── Job 1: Lint ──────────────────────────────────────────────────────────── + lint: + name: Lint (Ruff) runs-on: ubuntu-latest steps: @@ -22,17 +27,106 @@ jobs: python-version: "3.13" cache: "pip" - - name: Install dependencies + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff check + run: ruff check --output-format=github . + + - name: Run Ruff format check + run: ruff format --check . + + # ─── Job 2: Unit & Integration Tests ──────────────────────────────────────── + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + needs: lint + + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + # PyQt6 needs a display; Xvfb provides a virtual one + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + tesseract-ocr \ + libgl1 \ + xvfb \ + libxkbcommon-x11-0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + libxcb-xfixes0 + + - name: Install Python dependencies run: | python -m pip install --upgrade pip - # Instalăm dependențele din requirements.txt dacă există - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - # Adăugăm extensia pentru adnotări pytest pe GitHub - pip install ruff pytest pytest-github-actions-annotate-failures + pip install -r requirements.txt + pip install pytest pytest-github-actions-annotate-failures pytest-cov - - name: Run Ruff Check - run: ruff check --output-format=github . + # Create a minimal .env so the app doesn't crash on import + - name: Set up environment variables + run: | + cp .env.example .env + # Override to use a dummy Google key (Ollama not available in CI) + sed -i 's/AI_PROVIDER=ollama/AI_PROVIDER=google/' .env + sed -i 's/GOOGLE_API_KEY=your_google_api_key_here/GOOGLE_API_KEY=ci-dummy-key/' .env + env: + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + + - name: Run unit tests (core + ai models — no live AI calls) + run: | + xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ + pytest tests/ \ + --ignore=tests/evals \ + --ignore=tests/test_agent_decider.py \ + -v \ + --tb=short \ + --cov=core \ + --cov=ai \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml + env: + AI_PROVIDER: google + GOOGLE_API_KEY: ci-dummy-key + PYTHONDONTWRITEBYTECODE: "1" + QT_QPA_PLATFORM: offscreen + + - name: Upload coverage report + if: matrix.python-version == '3.13' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml + retention-days: 7 - - name: Run tests with Pytest - if: always() # Rulează testele chiar dacă Ruff a picat, ca să vezi TOATE erorile - run: pytest tests/ + # ─── Job 3: Summary gate (required by branch protection rule) ─────────────── + build-and-test: + name: build-and-test + runs-on: ubuntu-latest + needs: [lint, test] + if: always() + steps: + - name: Check all jobs passed + run: | + if [[ "${{ needs.lint.result }}" != "success" || "${{ needs.test.result }}" != "success" ]]; then + echo "One or more required jobs failed." + exit 1 + fi + echo "All checks passed." diff --git a/Quarantine/Saptamana2LaboratorMDS.pdf b/Quarantine/Saptamana2LaboratorMDS.pdf new file mode 100644 index 0000000..713644c Binary files /dev/null and b/Quarantine/Saptamana2LaboratorMDS.pdf differ diff --git a/ai/agent_compiler.py b/ai/agent_compiler.py index 54b08be..8327226 100644 --- a/ai/agent_compiler.py +++ b/ai/agent_compiler.py @@ -48,21 +48,21 @@ class CompiledRule(BaseModel): You are an expert rule translator for the ClutterKill system. Your job is to translate a user's natural language instruction about where and how to save files into a structured JSON rule. +{format_instructions} + User instruction: "{user_prompt}" Extract the category, folder structure, and naming convention. If the naming convention is not explicitly stated, use a default placeholder like "{{original_filename}}" or infer a sensible one if the context implies it. -IMPORTANT: You must return ONLY the raw JSON object containing the actual values. Do NOT return a JSON schema, and do NOT wrap your answer in "properties". +CRITICAL: You must return ONLY the raw JSON object containing the ACTUAL values based on the user instruction. Do NOT return a JSON schema. Do NOT return properties definitions. DO NOT echo back the format instructions. -Example of valid output: +Example of expected valid output: {{ "category": "factura", "folder_structure": "Facturi", "naming_convention": "factura_data.pdf" }} - -{format_instructions} """ diff --git a/ai/agent_decider.py b/ai/agent_decider.py index a86c79a..f13f2d7 100644 --- a/ai/agent_decider.py +++ b/ai/agent_decider.py @@ -60,6 +60,8 @@ def sanitize_filename(cls, v: str) -> str: You are an expert decision-making agent for the ClutterKill system. Your job is to analyze a document summary and a set of organization rules, and decide if the document should be moved to the correct folder or placed in quarantine. +{format_instructions} + Rule Category: {rule_category} Target Folder: {rule_folder} Naming Convention: {rule_naming} @@ -82,9 +84,7 @@ def sanitize_filename(cls, v: str) -> str: 5. CRITICAL: Do NOT include spaces in the filename. Use underscores (_) instead. 6. If the status is "quarantine", the folder must be "Quarantine". -IMPORTANT: You must return ONLY the raw JSON object containing the actual values. Do NOT return a JSON schema, and do NOT wrap your answer in markdown fences (like ```json). - -{format_instructions} +CRITICAL: You must return ONLY the raw JSON object containing the ACTUAL values based on your decision. Do NOT return a JSON schema. Do NOT return properties definitions. DO NOT echo back the format instructions. """ _REPAIR_PROMPT = ChatPromptTemplate.from_messages( @@ -215,7 +215,7 @@ def decide( test_filename = "doc_scanned_123.pdf" print(f"\n{'=' * 60}") - print("TEST 1: Sanitizare și Retry") + print("TEST 1: Sanitizare si Retry") try: decision1 = agent.decide(test_summary_match, test_filename, test_rule) print("Output JSON (observă cum / a fost înlocuit):") diff --git a/ai/tools.py b/ai/tools.py index aadccff..4cdb019 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -2,7 +2,7 @@ Extraction Tools — ai/tools.py Acest modul conține funcții utilitare pentru extragerea textului din -diferite tipuri de fișiere (PDF, imagini), folosite ulterior de către +diferite tipuri de fișiere (PDF, imagini, Word), folosite ulterior de către agenții AI pentru procesare. """ @@ -12,6 +12,7 @@ from pathlib import Path from typing import Union +import docx # python-docx import fitz # PyMuPDF import pytesseract from PIL import Image @@ -91,3 +92,29 @@ def extract_text_from_image(path: Union[str, Path]) -> str: except Exception as e: logger.error(f"Eroare la extragerea textului din imagine ({file_path}): {e}") return "" + + +def extract_text_from_docx(path: Union[str, Path]) -> str: + """ + Extrage textul dintr-un fișier Word (.docx) folosind python-docx. + + Parcurge toate paragrafele documentului și le concatenează cu newline. + + Args: + path: Calea către fișierul .docx. + + Returns: + Textul extras din document ca string. Returnează un string gol în caz de eroare. + """ + file_path = Path(path) + if not file_path.exists(): + logger.error(f"Fișierul Word nu a fost găsit: {file_path}") + return "" + + try: + doc = docx.Document(str(file_path)) + paragraphs = [para.text for para in doc.paragraphs if para.text.strip()] + return "\n".join(paragraphs).strip() + except Exception as e: + logger.error(f"Eroare la extragerea textului din Word ({file_path}): {e}") + return "" diff --git a/core/quarantine_db.py b/core/quarantine_db.py index 7bbf848..56d9be1 100644 --- a/core/quarantine_db.py +++ b/core/quarantine_db.py @@ -179,9 +179,7 @@ def remove(self, record_id: int) -> bool: """ conn = self._get_connection() try: - cursor = conn.execute( - "DELETE FROM quarantine WHERE id = ?", (record_id,) - ) + cursor = conn.execute("DELETE FROM quarantine WHERE id = ?", (record_id,)) conn.commit() return cursor.rowcount > 0 finally: diff --git a/core/scan_worker.py b/core/scan_worker.py index 3db19e5..2dd0d98 100644 --- a/core/scan_worker.py +++ b/core/scan_worker.py @@ -6,7 +6,11 @@ from ai.agent_compiler import CompilerAgent from ai.agent_extractor import ExtractorAgent from ai.agent_decider import DeciderAgent -from ai.tools import extract_text_from_pdf, extract_text_from_image +from ai.tools import ( + extract_text_from_pdf, + extract_text_from_image, + extract_text_from_docx, +) from core.file_manager import move_and_rename_file from core.quarantine_db import quarantine_db @@ -94,6 +98,8 @@ def run(self): text = extract_text_from_pdf(file_path) elif ext in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]: text = extract_text_from_image(file_path) + elif ext == ".docx": + text = extract_text_from_docx(file_path) elif ext in [".txt", ".csv", ".md"]: text = file_path.read_text(errors="ignore") else: diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..2a89d17 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,13 @@ +{ + "pythonVersion": "3.13", + "pythonPlatform": "Windows", + "pythonPath": "C:\\Python313\\python.exe", + "venvPath": ".", + "include": [ + "." + ], + "extraPaths": [ + "." + ], + "typeCheckingMode": "basic" +} diff --git a/scripts/generate_mock_data.py b/scripts/generate_mock_data.py index e69de29..faa4dd8 100644 --- a/scripts/generate_mock_data.py +++ b/scripts/generate_mock_data.py @@ -0,0 +1,392 @@ +""" +generate_mock_data.py — scripts/generate_mock_data.py + +Generează 20 de fișiere neorganizate în test_data/source/: + - 7 fișiere PDF (facturi / documente de curs) folosind fpdf + - 7 imagini PNG (scanări simulate) folosind Pillow + - 6 documente Word (.docx) folosind python-docx + +Toate fișierele conțin cuvinte cheie: „Factură", „Curs", „Semestru". +""" + +import random +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Asigurăm că rădăcina proiectului este în sys.path (rulare din orice dir) +# --------------------------------------------------------------------------- +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from fpdf import FPDF # noqa: E402 +from PIL import Image, ImageDraw, ImageFont # noqa: E402 +from docx import Document # noqa: E402 +from docx.shared import Pt # noqa: E402 + +# --------------------------------------------------------------------------- +# Directorul destinație +# --------------------------------------------------------------------------- +OUTPUT_DIR = PROJECT_ROOT / "test_data" / "source" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +# --------------------------------------------------------------------------- +# Date de test (variabile pentru a genera fișiere distincte) +# --------------------------------------------------------------------------- +FURNIZORI = [ + "SC TechSoft SRL", + "Universitatea Politehnica", + "EduConsult SA", + "InfoSystems SRL", + "AcademiaPro", + "DataVision SRL", + "SmartLearn SRL", +] + +CURSURI = [ + "Metode de Dezvoltare a Softului", + "Structuri de Date si Algoritmi", + "Baze de Date Avansate", + "Ingineria Sistemelor Software", + "Retele de Calculatoare", + "Inteligenta Artificiala", + "Programare Orientata Obiect", +] + +SEMESTRE = ["Semestrul 1", "Semestrul 2", "Semestrul I", "Semestrul II"] + +DATE = [ + "10.01.2025", + "15.02.2025", + "03.03.2025", + "22.04.2025", + "07.05.2025", + "18.06.2025", + "30.09.2025", + "11.10.2025", + "25.11.2025", + "02.12.2025", + "14.01.2026", + "28.02.2026", +] + +PRODUSE = [ + ("Licenta software educational", 1, 450.00), + ("Suport curs semestrial", 2, 200.00), + ("Acces platforma e-learning", 1, 120.00), + ("Manual digital Semestrul 2", 3, 75.00), + ("Consultanta implementare proiect", 5, 150.00), + ("Abonament resurse academice", 1, 350.00), + ("Kit laborator programare", 2, 180.00), +] + + +def _rand(lst): + return random.choice(lst) + + +def _p(text: str) -> str: + """Strip orice caracter non-latin-1 (necesar pentru fpdf v1).""" + return text.encode("latin-1", errors="ignore").decode("latin-1") + + +# =========================================================================== +# 1. PDF — 7 fișiere +# =========================================================================== + + +def _make_pdf(index: int) -> Path: + """Generează o factură PDF cu fpdf.""" + furnizor = FURNIZORI[index % len(FURNIZORI)] + curs = CURSURI[index % len(CURSURI)] + semestru = _rand(SEMESTRE) + data = DATE[index % len(DATE)] + nr_factura = f"FCT-{2025 + index % 2}-{1000 + index * 37}" + produs_name, qty, pret_unit = PRODUSE[index % len(PRODUSE)] + total = qty * pret_unit + tva = round(total * 0.19, 2) + + pdf = FPDF() + pdf.add_page() + + # Titlu + pdf.set_font("Helvetica", "B", 18) + pdf.set_fill_color(30, 60, 120) + pdf.set_text_color(255, 255, 255) + pdf.cell(0, 12, _p("FACTURA FISCALA"), ln=True, align="C", fill=True) + pdf.ln(4) + + # Antet document + pdf.set_text_color(0, 0, 0) + pdf.set_font("Helvetica", "", 11) + pdf.cell(90, 8, _p(f"Numar Factura: {nr_factura}"), ln=False) + pdf.cell(0, 8, _p(f"Data: {data}"), ln=True) + pdf.cell(90, 8, _p(f"Furnizor: {furnizor}"), ln=False) + pdf.cell(0, 8, _p("Client: Universitatea Tehnica"), ln=True) + pdf.ln(4) + + # Linie separatoare + pdf.set_draw_color(100, 100, 100) + pdf.line(10, pdf.get_y(), 200, pdf.get_y()) + pdf.ln(4) + + # Detalii curs / semestru + pdf.set_font("Helvetica", "B", 11) + pdf.cell(0, 8, _p(f"Curs: {curs}"), ln=True) + pdf.cell(0, 8, _p(f"Perioada: {semestru} 2024-2025"), ln=True) + pdf.ln(4) + + # Tabel produse + pdf.set_fill_color(200, 215, 240) + pdf.set_font("Helvetica", "B", 10) + pdf.cell(80, 8, _p("Descriere produs/serviciu"), border=1, fill=True) + pdf.cell(20, 8, _p("Cant."), border=1, fill=True, align="C") + pdf.cell(35, 8, _p("Pret unit. (RON)"), border=1, fill=True, align="C") + pdf.cell(35, 8, _p("Total (RON)"), border=1, fill=True, align="C", ln=True) + + pdf.set_font("Helvetica", "", 10) + pdf.cell(80, 8, _p(produs_name), border=1) + pdf.cell(20, 8, str(qty), border=1, align="C") + pdf.cell(35, 8, f"{pret_unit:.2f}", border=1, align="R") + pdf.cell(35, 8, f"{total:.2f}", border=1, align="R", ln=True) + + pdf.ln(2) + pdf.set_font("Helvetica", "", 10) + pdf.cell(135, 8, _p("Subtotal:"), align="R") + pdf.cell(35, 8, f"{total:.2f} RON", align="R", ln=True) + pdf.cell(135, 8, _p("TVA (19%):"), align="R") + pdf.cell(35, 8, f"{tva:.2f} RON", align="R", ln=True) + pdf.set_font("Helvetica", "B", 11) + pdf.cell(135, 8, _p("TOTAL DE PLATA:"), align="R") + pdf.cell(35, 8, f"{total + tva:.2f} RON", align="R", ln=True) + + pdf.ln(8) + pdf.set_font("Helvetica", "I", 9) + pdf.set_text_color(80, 80, 80) + pdf.cell( + 0, + 6, + _p(f"Aceasta Factura este generata automat. {semestru} - {curs}."), + ln=True, + align="C", + ) + + filename = OUTPUT_DIR / f"factura_{nr_factura.replace('-', '_')}.pdf" + pdf.output(str(filename)) + return filename + + +# =========================================================================== +# 2. Imagini PNG — 7 fișiere (scanări simulate) +# =========================================================================== + + +def _make_image(index: int) -> Path: + """Generează o imagine PNG simulând un document scanat.""" + furnizor = FURNIZORI[index % len(FURNIZORI)] + curs = CURSURI[index % len(CURSURI)] + semestru = SEMESTRE[index % len(SEMESTRE)] + data = DATE[(index + 3) % len(DATE)] + nr = f"IMG-{1000 + index * 53}" + + # Fundal ușor gălbui (hârtie veche) + bg_color = (252, 248, 230) if index % 2 == 0 else (255, 255, 255) + img = Image.new("RGB", (800, 600), color=bg_color) + draw = ImageDraw.Draw(img) + + # Încercăm un font mai mare; fallback la default + try: + font_title = ImageFont.truetype("arial.ttf", 26) + font_body = ImageFont.truetype("arial.ttf", 18) + font_small = ImageFont.truetype("arial.ttf", 14) + except OSError: + font_title = ImageFont.load_default() + font_body = font_title + font_small = font_title + + text_color = (20, 20, 20) + accent = (30, 60, 120) + + # Header + draw.rectangle([0, 0, 800, 55], fill=accent) + draw.text( + (20, 12), + "FACTURA FISCALA / DOCUMENT CURS", + font=font_title, + fill=(255, 255, 255), + ) + + y = 75 + draw.text((20, y), f"Nr. Document: {nr}", font=font_body, fill=text_color) + y += 30 + draw.text((20, y), f"Data emiterii: {data}", font=font_body, fill=text_color) + y += 30 + draw.text((20, y), f"Furnizor: {furnizor}", font=font_body, fill=text_color) + y += 30 + draw.text((20, y), f"Curs: {curs}", font=font_body, fill=accent) + y += 30 + draw.text( + (20, y), f"Semestru: {semestru} 2024-2025", font=font_body, fill=text_color + ) + y += 35 + + # Linie separator + draw.line([(20, y), (780, y)], fill=(150, 150, 150), width=2) + y += 15 + + draw.text((20, y), "Descriere:", font=font_body, fill=text_color) + y += 28 + draw.text( + (30, y), + "Acest document atesta plata serviciilor educationale aferente cursului", + font=font_small, + fill=text_color, + ) + y += 22 + draw.text( + (30, y), + f'"{curs}" desfasurat in {semestru}.', + font=font_small, + fill=text_color, + ) + y += 30 + + total = round(random.uniform(100, 800), 2) + draw.text( + (20, y), + f"Suma totala de plata: {total} RON (incl. TVA 19%)", + font=font_body, + fill=text_color, + ) + y += 35 + + draw.line([(20, y), (780, y)], fill=(150, 150, 150), width=1) + y += 10 + draw.text( + (20, y), + "Semnat si stampilat. Document valid in conformitate cu legislatia in vigoare.", + font=font_small, + fill=(100, 100, 100), + ) + + # Adăugăm un zgomot ușor pentru a simula scanarea + if index % 3 == 0: + pixels = img.load() + for _ in range(500): + rx = random.randint(0, 799) + ry = random.randint(56, 599) + pixels[rx, ry] = (random.randint(180, 220),) * 3 + + filename = OUTPUT_DIR / f"scan_document_{nr}.png" + img.save(str(filename)) + return filename + + +# =========================================================================== +# 3. Word Documents (.docx) — 6 fișiere +# =========================================================================== + + +def _make_docx(index: int) -> Path: + """Generează un document Word cu suport de curs sau factură consultanță.""" + furnizor = FURNIZORI[index % len(FURNIZORI)] + curs = CURSURI[index % len(CURSURI)] + semestru = SEMESTRE[index % len(SEMESTRE)] + data = DATE[(index + 5) % len(DATE)] + nr = f"DOC-{2025 + index % 2}-{500 + index * 61}" + + doc = Document() + + # Stil titlu + title = doc.add_heading(level=1) + title.clear() + run = title.add_run(f"Factură / Suport Curs — {semestru}") + run.font.size = Pt(18) + + doc.add_paragraph(f"Număr document: {nr}") + doc.add_paragraph(f"Data emiterii: {data}") + doc.add_paragraph(f"Furnizor: {furnizor}") + doc.add_paragraph("") + + doc.add_heading("Detalii Curs", level=2) + doc.add_paragraph(f"Curs: {curs}") + doc.add_paragraph(f"Semestru: {semestru} 2024-2025") + doc.add_paragraph( + f"Acest document reprezintă materialul suport pentru cursul «{curs}» " + f"organizat în cadrul {semestru} al anului universitar 2024-2025." + ) + + doc.add_heading("Factură Consultanță", level=2) + descriere_para = doc.add_paragraph() + descriere_para.add_run( + f"Factura nr. {nr} emisă de {furnizor} pentru servicii de consultanță " + f"și suport educațional aferent cursului {curs}, {semestru}." + ) + + # Tabel produse + table = doc.add_table(rows=1, cols=4) + table.style = "Table Grid" + hdr = table.rows[0].cells + hdr[0].text = "Descriere" + hdr[1].text = "Cantitate" + hdr[2].text = "Preț unitar (RON)" + hdr[3].text = "Total (RON)" + + produs_name, qty, pret_unit = PRODUSE[index % len(PRODUSE)] + total = qty * pret_unit + row = table.add_row().cells + row[0].text = produs_name + row[1].text = str(qty) + row[2].text = f"{pret_unit:.2f}" + row[3].text = f"{total:.2f}" + + doc.add_paragraph("") + total_tva = round(total * 1.19, 2) + doc.add_paragraph(f"Total de plată (incl. TVA 19%): {total_tva:.2f} RON") + + doc.add_heading("Semnătură și Confirmare", level=2) + doc.add_paragraph( + "Document semnat electronic. Valid pentru Semestrul în curs. " + f"Curs desfășurat conform planului universitar — {semestru}." + ) + + filename = OUTPUT_DIR / f"document_{nr.replace('-', '_')}.docx" + doc.save(str(filename)) + return filename + + +# =========================================================================== +# Main +# =========================================================================== + + +def main(): + print(f"[INFO] Director destinatie: {OUTPUT_DIR}") + print("[INFO] Generare fisiere mock...\n") + + generated = [] + + # 7 PDF-uri + for i in range(7): + path = _make_pdf(i) + generated.append(path) + print(f" [PDF {i + 1}/7] {path.name}") + + # 7 Imagini PNG + for i in range(7): + path = _make_image(i) + generated.append(path) + print(f" [IMG {i + 1}/7] {path.name}") + + # 6 Documente Word + for i in range(6): + path = _make_docx(i) + generated.append(path) + print(f" [DOC {i + 1}/6] {path.name}") + + print(f"\n[DONE] Generat {len(generated)} fisiere in {OUTPUT_DIR}") + return generated + + +if __name__ == "__main__": + main() diff --git a/scripts/test_tools.py b/scripts/test_tools.py index f2199a9..4bb2d32 100644 --- a/scripts/test_tools.py +++ b/scripts/test_tools.py @@ -13,13 +13,16 @@ def create_test_image(path: str): img.save(path) print(f"✅ Imagine de test creată: {path}") + def main(): print("--- Testare Tool-uri Extracție ---") - + # 1. Testare PDF pdf_path = "Curs_MDS_Sem2.pdf" if not os.path.exists(pdf_path): - print(f"Atenție: {pdf_path} nu a fost găsit. Te rog generează-l mai întâi cu create_test_pdf.py.") + print( + f"Atenție: {pdf_path} nu a fost găsit. Te rog generează-l mai întâi cu create_test_pdf.py." + ) else: print(f"\nExtragere text din {pdf_path}:") pdf_text = extract_text_from_pdf(pdf_path) diff --git a/tests/test_core.py b/tests/test_core.py index ae107fb..bcc33ec 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,22 +1,169 @@ +""" +Teste pentru mutarea fișierelor — core/file_manager.py + +Acoperire: + - Mutare și redenumire de bază (happy path) + - Crearea automată a subdirectoarelor destinație + - Conținut păstrat după mutare + - Eroare la sursă inexistentă + - Mutare cu același nume (fără redenumire) + - Mutare fișier binar + - Mutare mai multor fișiere succesiv + - Înregistrarea acțiunii în undo_manager după mutare +""" + +import pytest + from core.file_manager import move_and_rename_file +from core.undo_manager import undo_manager + + +# ─── Fixture: resetăm undo_manager înaintea fiecărui test ───────────────────── + + +@pytest.fixture(autouse=True) +def clear_undo_history(): + """Curăță istoricul global undo_manager înainte și după fiecare test.""" + undo_manager.history.clear() + yield + undo_manager.history.clear() + + +# ─── Happy path ──────────────────────────────────────────────────────────────── + + +def test_move_basic(tmp_path): + """Mutarea unui fișier simplu cu redenumire.""" + src = tmp_path / "source" / "original.txt" + src.parent.mkdir() + src.write_text("hello world") + + dest_dir = tmp_path / "destination" + result = move_and_rename_file(src, dest_dir, "renamed.txt") + + assert result.exists() + assert result.name == "renamed.txt" + assert result.parent == dest_dir + assert not src.exists() + + +def test_move_preserves_content(tmp_path): + """Conținutul fișierului trebuie să fie identic după mutare.""" + content = "Linie 1\nLinie 2\nLinie 3\n" + src = tmp_path / "in" / "data.txt" + src.parent.mkdir() + src.write_text(content) + + result = move_and_rename_file(src, tmp_path / "out", "data_moved.txt") + + assert result.read_text() == content + + +def test_move_creates_nested_destination(tmp_path): + """Directorul destinație (inclusiv subdirectoare) se creează automat.""" + src = tmp_path / "src.txt" + src.write_text("content") + + deep_dest = tmp_path / "a" / "b" / "c" + result = move_and_rename_file(src, deep_dest, "file.txt") + + assert result.exists() + assert result.parent == deep_dest + + +def test_move_same_name(tmp_path): + """Mutare fără redenumire efectivă (același nume).""" + src = tmp_path / "source" / "doc.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4 dummy") + result = move_and_rename_file(src, tmp_path / "dest", "doc.pdf") -def test_move_and_rename_file(tmp_path): - # Setup - src_dir = tmp_path / "source" + assert result.name == "doc.pdf" + assert result.read_bytes() == b"%PDF-1.4 dummy" + assert not src.exists() + + +def test_move_binary_file(tmp_path): + """Fișierele binare (ex: PNG) trebuie mutate corect fără corupere.""" + binary_data = bytes(range(256)) * 4 + src = tmp_path / "image.png" + src.write_bytes(binary_data) + + result = move_and_rename_file(src, tmp_path / "images", "image_moved.png") + + assert result.read_bytes() == binary_data + + +def test_move_multiple_files_sequentially(tmp_path): + """Mutarea mai multor fișiere una după alta — toate ajung la destinație.""" + src_dir = tmp_path / "src" src_dir.mkdir() - src_file = src_dir / "test.txt" - src_file.write_text("hello world") + dest_dir = tmp_path / "dest" + + files = [] + for i in range(5): + f = src_dir / f"file_{i}.txt" + f.write_text(f"content {i}") + files.append(f) + + results = [ + move_and_rename_file(f, dest_dir, f"moved_{i}.txt") for i, f in enumerate(files) + ] + + for i, r in enumerate(results): + assert r.exists(), f"Fișierul {r} nu există" + assert r.read_text() == f"content {i}" + + # Sursele nu mai există + for f in files: + assert not f.exists() + + +# ─── Error cases ─────────────────────────────────────────────────────────────── + + +def test_move_source_not_found_raises(tmp_path): + """Dacă sursa nu există, trebuie ridicată FileNotFoundError.""" + nonexistent = tmp_path / "ghost.txt" + with pytest.raises(FileNotFoundError): + move_and_rename_file(nonexistent, tmp_path / "dest", "out.txt") + + +def test_move_source_is_directory_raises(tmp_path): + """Dacă sursa este un director (nu fișier), trebuie ridicată FileNotFoundError.""" + a_dir = tmp_path / "adir" + a_dir.mkdir() + with pytest.raises(FileNotFoundError): + move_and_rename_file(a_dir, tmp_path / "dest", "out.txt") + + +# ─── Integrare cu undo_manager ───────────────────────────────────────────────── + + +def test_move_records_action_in_undo(tmp_path): + """Fiecare mutare trebuie înregistrată în undo_manager.history.""" + src = tmp_path / "file.txt" + src.write_text("data") + + assert len(undo_manager.history) == 0 + + result = move_and_rename_file(src, tmp_path / "dest", "file_new.txt") + + assert len(undo_manager.history) == 1 + action = undo_manager.history[-1] + assert action["old_path"] == src + assert action["new_path"] == result - dest_dir = tmp_path / "destination" / "subfolder" - # Execute - new_name = "renamed_test.txt" - result_path = move_and_rename_file(src_file, dest_dir, new_name) +def test_multiple_moves_record_multiple_actions(tmp_path): + """N mutări → N intrări în undo_manager.history.""" + n = 4 + results = [] + for i in range(n): + f = tmp_path / f"src_{i}.txt" + f.write_text(f"data {i}") + r = move_and_rename_file(f, tmp_path / "dest", f"dst_{i}.txt") + results.append(r) - # Assert - assert result_path.exists() - assert result_path.name == new_name - assert result_path.parent == dest_dir - assert result_path.read_text() == "hello world" - assert not src_file.exists() + assert len(undo_manager.history) == n diff --git a/tests/test_undo.py b/tests/test_undo.py index 5be5cc3..1bd34e5 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -1,32 +1,287 @@ +""" +Teste pentru stiva de undo — core/undo_manager.py + +Acoperire: + - UndoManager direct (fără file_manager): + record_action / undo_last_action / undo_action(index) / get_history + - Comportament cu stivă goală + - Undo când fișierul destinație lipseşte (mutare externă) + - max_history: limitarea automată a deque-ului + - undo_action(index) – anulare selectivă + - get_history – formatul returnat + - Integrare cu file_manager (end-to-end): + undo după move, undo multiplu, undo în ordine inversă +""" + +import pytest +from pathlib import Path + +from core.undo_manager import UndoManager from core.file_manager import move_and_rename_file -from core.undo_manager import undo_manager +from core.undo_manager import undo_manager as global_undo + + +# ─── Fixture: instanță izolată + curăță global undo ─────────────────────────── + + +@pytest.fixture() +def um(): + """Returnează un UndoManager proaspăt pentru fiecare test.""" + return UndoManager() + + +@pytest.fixture(autouse=True) +def clear_global_undo(): + """Curăță istoricul global înainte și după fiecare test.""" + global_undo.history.clear() + yield + global_undo.history.clear() + + +# ─── record_action ───────────────────────────────────────────────────────────── + + +def test_record_action_adds_to_history(um): + """record_action trebuie să adauge exact o intrare în history.""" + um.record_action("/old/path.txt", "/new/path.txt") + assert len(um.history) == 1 + + +def test_record_action_stores_paths_as_path_objects(um): + """Căile stocate trebuie să fie obiecte Path.""" + um.record_action("/old/file.txt", "/new/file.txt") + action = um.history[0] + assert isinstance(action["old_path"], Path) + assert isinstance(action["new_path"], Path) + + +def test_record_multiple_actions(um): + """Mai multe apeluri creează mai multe intrări, în ordine.""" + um.record_action("/a.txt", "/b.txt") + um.record_action("/c.txt", "/d.txt") + um.record_action("/e.txt", "/f.txt") + assert len(um.history) == 3 + assert um.history[0]["old_path"] == Path("/a.txt") + assert um.history[2]["old_path"] == Path("/e.txt") + + +# ─── undo_last_action ────────────────────────────────────────────────────────── + + +def test_undo_last_action_empty_history(um): + """Undo pe stivă goală → False, fără excepție.""" + assert um.undo_last_action() is False + + +def test_undo_last_action_removes_entry(tmp_path, um): + """După undo cu succes, intrarea trebuie eliminată din history.""" + src = tmp_path / "src.txt" + dst = tmp_path / "dst.txt" + src.write_text("data") + src.rename(dst) # mutăm manual + um.record_action(src, dst) + + result = um.undo_last_action() + + assert result is True + assert len(um.history) == 0 + + +def test_undo_last_action_file_moved_back(tmp_path, um): + """Fișierul trebuie readus fizic la calea originală.""" + src = tmp_path / "original.txt" + dst = tmp_path / "moved" / "renamed.txt" + src.write_text("restore me") + dst.parent.mkdir() + src.rename(dst) + um.record_action(src, dst) + + um.undo_last_action() + + assert src.exists() + assert src.read_text() == "restore me" + assert not dst.exists() + + +def test_undo_last_action_returns_false_when_dest_missing(tmp_path, um): + """Dacă fișierul mutat nu mai există la destinație → False (nu crașează).""" + um.record_action(tmp_path / "old.txt", tmp_path / "new.txt") + # new.txt nu există pe disc + assert um.undo_last_action() is False + + +def test_undo_last_action_lifo_order(tmp_path, um): + """Undo respectă ordinea LIFO (Last In, First Out).""" + f1 = tmp_path / "f1.txt" + f2 = tmp_path / "f2.txt" + d1 = tmp_path / "d1.txt" + d2 = tmp_path / "d2.txt" + + f1.write_text("first") + f2.write_text("second") + f1.rename(d1) + f2.rename(d2) + + um.record_action(f1, d1) + um.record_action(f2, d2) + + # Al doilea înregistrat → primul undo + um.undo_last_action() + assert f2.exists() + assert not d2.exists() + + # Al primul înregistrat → al doilea undo + um.undo_last_action() + assert f1.exists() + assert not d1.exists() + + +# ─── undo_action(index) ──────────────────────────────────────────────────────── + + +def test_undo_action_by_index(tmp_path, um): + """undo_action(index) anulează acțiunea de la indexul dat.""" + src0 = tmp_path / "s0.txt" + dst0 = tmp_path / "d0.txt" + src1 = tmp_path / "s1.txt" + dst1 = tmp_path / "d1.txt" + + src0.write_text("zero") + src1.write_text("one") + src0.rename(dst0) + src1.rename(dst1) + + um.record_action(src0, dst0) # index 0 + um.record_action(src1, dst1) # index 1 + + # Anulăm acțiunea de la index 0 (prima mutare) + result = um.undo_action(0) + + assert result is True + assert src0.exists() + assert not dst0.exists() + # Acțiunea de la index 1 rămâne + assert len(um.history) == 1 + + +def test_undo_action_invalid_index(um): + """Index out of range → False, fără excepție.""" + um.record_action("/a.txt", "/b.txt") + assert um.undo_action(5) is False + assert um.undo_action(-1) is False + + +def test_undo_action_empty_history(um): + """undo_action pe stivă goală → False.""" + assert um.undo_action(0) is False + + +# ─── max_history ─────────────────────────────────────────────────────────────── + + +def test_max_history_limits_deque(): + """Depășirea max_history → cele mai vechi intrări sunt șterse automat.""" + um = UndoManager(max_history=3) + for i in range(10): + um.record_action(f"/old/{i}.txt", f"/new/{i}.txt") + + assert len(um.history) == 3 + # Ultimele 3 acțiuni (7, 8, 9) trebuie reținute + assert um.history[-1]["old_path"] == Path("/old/9.txt") + assert um.history[0]["old_path"] == Path("/old/7.txt") + + +# ─── get_history ─────────────────────────────────────────────────────────────── + + +def test_get_history_returns_list_of_dicts(um): + """get_history returnează o listă de dict-uri cu chei string.""" + um.record_action("/src/a.txt", "/dst/a.txt") + um.record_action("/src/b.txt", "/dst/b.txt") + + history = um.get_history() + + assert isinstance(history, list) + assert len(history) == 2 + assert isinstance(history[0], dict) + assert "old_path" in history[0] + assert "new_path" in history[0] + + +def test_get_history_paths_are_strings(um): + """Căile din get_history trebuie să fie string-uri (nu Path), pentru UI.""" + um.record_action(Path("/src/x.txt"), Path("/dst/x.txt")) + history = um.get_history() + assert isinstance(history[0]["old_path"], str) + assert isinstance(history[0]["new_path"], str) + + +def test_get_history_empty(um): + """get_history pe stivă goală → listă goală.""" + assert um.get_history() == [] + + +# ─── Integrare end-to-end cu file_manager ───────────────────────────────────── + + +def test_integration_move_then_undo(tmp_path): + """End-to-end: move_and_rename_file + undo_last_action readuce fișierul.""" + src = tmp_path / "source" / "doc.txt" + src.parent.mkdir() + src.write_text("important content") + + target = move_and_rename_file(src, tmp_path / "dest", "doc_renamed.txt") + assert target.exists() + assert not src.exists() + + success = global_undo.undo_last_action() + + assert success is True + assert src.exists() + assert src.read_text() == "important content" + assert not target.exists() + + +def test_integration_multiple_moves_undo_all(tmp_path): + """Undo pe toate mutările în ordine inversă (LIFO).""" + src_files = [] + dst_files = [] + + for i in range(3): + f = tmp_path / "src" / f"file_{i}.txt" + f.parent.mkdir(exist_ok=True) + f.write_text(f"content {i}") + src_files.append(f) + d = move_and_rename_file(f, tmp_path / "dst", f"moved_{i}.txt") + dst_files.append(d) + + # Undo complet în ordine inversă + for i in range(3): + assert global_undo.undo_last_action() is True + for i, src in enumerate(src_files): + assert src.exists(), f"Fișierul {src} nu a fost restaurat" + assert not dst_files[i].exists() -def test_undo_last_action(tmp_path): - # Setup - src_dir = tmp_path / "source" - src_dir.mkdir() - src_file = src_dir / "test.txt" - src_file.write_text("Test content for undo") - dest_dir = tmp_path / "destination" +def test_integration_undo_restores_to_original_subdirectory(tmp_path): + """Undo recreează directorul sursă dacă a fost șters între timp.""" + src_dir = tmp_path / "deep" / "nested" / "dir" + src_dir.mkdir(parents=True) + src = src_dir / "file.txt" + src.write_text("nested content") - # Ensure undo manager history is empty before testing - undo_manager.history.clear() + move_and_rename_file(src, tmp_path / "flat", "file_flat.txt") - # Execute Move - new_name = "renamed_test.txt" - target_path = move_and_rename_file(src_file, dest_dir, new_name) + # Simulăm că directorul sursă a fost șters manual + import shutil - # Confirm it was moved - assert target_path.exists() - assert not src_file.exists() + shutil.rmtree(str(tmp_path / "deep")) + assert not src_dir.exists() - # Execute Undo (This is the verification step required by the task) - success = undo_manager.undo_last_action() + # Undo trebuie să recreeze directorul și să readucă fișierul + success = global_undo.undo_last_action() - # Assert Undo was successful and file is back at original location assert success is True - assert src_file.exists() - assert src_file.read_text() == "Test content for undo" - assert not target_path.exists() + assert src.exists() + assert src.read_text() == "nested content" diff --git a/ui/tabs/history_tab.py b/ui/tabs/history_tab.py index 2848c5c..06934c0 100644 --- a/ui/tabs/history_tab.py +++ b/ui/tabs/history_tab.py @@ -136,7 +136,9 @@ def _undo_action(self, index): if success: self.show_status("Undo reusit! Fisierul a fost readus.", "#4caf50") else: - self.show_status("Nu s-a putut face undo (fisierul nu mai exista?).", "#ff5c5c") + self.show_status( + "Nu s-a putut face undo (fisierul nu mai exista?).", "#ff5c5c" + ) self.refresh() diff --git a/ui/tabs/quarantine_tab.py b/ui/tabs/quarantine_tab.py index 0d22769..09e40a0 100644 --- a/ui/tabs/quarantine_tab.py +++ b/ui/tabs/quarantine_tab.py @@ -110,7 +110,9 @@ def init_ui(self): right_layout.addStretch() # Mesaj când nu sunt fișiere - self.empty_label = QLabel("✅ Niciun fișier în carantină!\nRulează un Scan pentru a adăuga fișiere.") + self.empty_label = QLabel( + "✅ Niciun fișier în carantină!\nRulează un Scan pentru a adăuga fișiere." + ) self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty_label.setStyleSheet("color: #7d7d7d; font-size: 14px;") self.empty_label.hide()