File size: 8,271 Bytes
f8c73f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
from __future__ import annotations

import os
import shutil
import threading
import time
from copy import deepcopy
from dataclasses import asdict
from pathlib import Path

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

import pytest
from PySide6.QtCore import QCoreApplication, QThreadPool, QTimer, QObject, Signal
from PySide6.QtWidgets import QApplication, QStackedWidget, QWidget

from adam.assets import AssetRegistry
from adam.config import ConfigManager
from adam.experiment_tracker import ExperimentStore
from adam.ui.asset_refresh import AssetRefresh
from adam.ui.background import BackgroundRead
from adam.ui.model_intelligence import ModelIntelligencePage
from adam.ui.main_window import (
    expand_subject_reference,
    is_explicit_action_request,
    is_pending_job_confirmation_request,
    subject_idea_from_chat,
)


@pytest.fixture(scope="module")
def app():
    instance = QCoreApplication.instance()
    if instance is not None and not isinstance(instance, QApplication):
        pytest.skip("Run UI loading tests separately from tests that create QCoreApplication.")
    return instance or QApplication([])


def pump(app, condition, timeout=5):
    deadline = time.monotonic() + timeout
    while not condition() and time.monotonic() < deadline:
        app.processEvents()
        time.sleep(0.005)
    assert condition()


def test_background_read_keeps_navigation_responsive_and_delivers_on_ui_thread(app):
    stack = QStackedWidget()
    stack.addWidget(QWidget())
    stack.addWidget(QWidget())
    reader = BackgroundRead(stack)
    gate = threading.Event()
    started = threading.Event()
    results = []
    reader.ready.connect(lambda value: results.append((value, threading.get_ident())))

    def slow_read():
        started.set()
        assert gate.wait(3)
        return "loaded"

    try:
        reader.request(slow_read)
        pump(app, started.is_set)
        QTimer.singleShot(0, lambda: stack.setCurrentIndex(1))
        pump(app, lambda: stack.currentIndex() == 1)
        assert results == []
    finally:
        gate.set()
    pump(app, lambda: bool(results))
    assert results == [("loaded", threading.get_ident())]


def test_chat_action_requests_are_routed_to_the_validated_planner() -> None:
    assert is_explicit_action_request("Can you train a DDPM on cozy cafe images?")
    assert is_explicit_action_request("Yes, start this job now")
    assert not is_explicit_action_request("What subject should we train today?")
    assert not is_explicit_action_request("Explain how DDPM training works")
    assert is_pending_job_confirmation_request("Yes, start this job now")
    assert not is_pending_job_confirmation_request("Can you train a DDPM on cozy cafe images?")


def test_subject_reference_is_expanded_from_the_prior_chat_idea() -> None:
    reply = """🎯 Subject Idea:
> “Astronauts Exploring a Floating City in Jupiter's Atmosphere”
"""
    assert subject_idea_from_chat(reply) == "Astronauts Exploring a Floating City in Jupiter's Atmosphere"
    request, subject = expand_subject_reference("Train a DDPM with that subject you mentioned", reply)
    assert subject == "Astronauts Exploring a Floating City in Jupiter's Atmosphere"
    assert request == "Train a DDPM with Astronauts Exploring a Floating City in Jupiter's Atmosphere"


def test_refresh_coalesces_requests_and_does_not_publish_stale_data(app):
    owner = QWidget()
    reader = BackgroundRead(owner)
    gate = threading.Event()
    results = []
    reader.ready.connect(results.append)
    try:
        reader.request(lambda: gate.wait(3) and "old")
        reader.request(lambda: "superseded", force=True)
        reader.request(lambda: "latest", force=True)
    finally:
        gate.set()
    pump(app, lambda: bool(results))
    assert results == ["latest"]
    reader.request(lambda: "unnecessary repeat")
    app.processEvents()
    assert not reader._running
    assert results == ["latest"]


def test_failed_read_can_be_retried(app):
    owner = QWidget()
    reader = BackgroundRead(owner)
    errors, results = [], []
    reader.failed.connect(errors.append)
    reader.ready.connect(results.append)

    def fail():
        raise OSError("disconnected")

    reader.request(fail)
    pump(app, lambda: bool(errors))
    reader.request(lambda: "recovered")
    pump(app, lambda: bool(results))
    assert errors == ["disconnected"]
    assert results == ["recovered"]


def test_asset_merge_preserves_changes_made_during_discovery(app, tmp_path):
    assets = AssetRegistry(tmp_path)
    original = assets.register(kind="model", name="Original", path=str(tmp_path / "original"))
    removed = assets.register(kind="model", name="Remove me", path=str(tmp_path / "removed"))
    baseline = {item.id: asdict(item) for item in assets.assets}
    discovered = deepcopy(assets.assets)
    original.name = "User edited name"
    assets.assets.remove(removed)
    new = assets.register(kind="model", name="Just trained", path=str(tmp_path / "new"))
    service = AssetRefresh(assets, ConfigManager(tmp_path))
    service._merge((baseline, discovered))
    assert {item.name for item in assets.assets} == {"User edited name", "Just trained"}
    assert assets.assets[0] is original
    assert new in assets.assets


def test_background_discovery_does_not_write_shared_state(app, tmp_path, monkeypatch):
    assets = AssetRegistry(tmp_path)
    config = ConfigManager(tmp_path)
    writes = []
    monkeypatch.setattr(AssetRegistry, "save", lambda self: writes.append(threading.get_ident()))
    monkeypatch.setattr("adam.dataset_registry.DatasetRegistry.save", lambda self: writes.append(threading.get_ident()))
    assets.discover(config, persist=False)
    assert writes == []


def test_intelligence_load_does_not_block_other_tabs(app, tmp_path, monkeypatch):
    gate = threading.Event()
    started = threading.Event()

    def history(*args, **kwargs):
        started.set()
        assert gate.wait(3)
        return []

    monkeypatch.setattr("adam.ui.model_intelligence.load_generation_history", history)
    page = ModelIntelligencePage(tmp_path, ExperimentStore(tmp_path))
    stack = QStackedWidget()
    stack.addWidget(page)
    stack.addWidget(QWidget())
    try:
        page.refresh()
        pump(app, started.is_set)
        QTimer.singleShot(0, lambda: stack.setCurrentIndex(1))
        pump(app, lambda: stack.currentIndex() == 1)
        assert page._reader._running
    finally:
        gate.set()
    pump(app, lambda: not page._reader._running)
    assert page.metric_models.text() == "0"
    QThreadPool.globalInstance().waitForDone(3000)


def test_generation_refresh_keeps_edits_and_folder_navigation_reuses_history(app, tmp_path, monkeypatch):
    from adam.registry import ToolRegistry
    from adam.ui.generations import GenerationsPage

    class Jobs(QObject):
        job_updated = Signal(object)

    config = ConfigManager(tmp_path)
    shutil.copy2(Path(__file__).resolve().parents[1] / "config/tools.json", tmp_path / "config/tools.json")
    assets = AssetRegistry(tmp_path)
    monkeypatch.setattr(AssetRefresh, "request", lambda *args, **kwargs: None)
    calls = []

    def history(*args, **kwargs):
        calls.append(1)
        return []

    monkeypatch.setattr("adam.ui.generations.load_generation_history", history)
    jobs = Jobs()
    page = GenerationsPage(tmp_path, ToolRegistry(tmp_path), jobs, assets, config)
    pump(app, lambda: not page._history_reader._running)
    page.provider.setCurrentIndex(page.provider.findData("ddpm_generator"))
    page.steps.setValue(37)
    page.seed.setText("123456")  # In-progress edit has not emitted editingFinished.
    page.prompt.setPlainText("Keep my current prompt")
    model = tmp_path / "model"
    model.mkdir()
    (model / "model_index.json").write_text("{}")
    assets.register(kind="model", name="New model", path=str(model), trainer="ddpm")
    page.refresh()
    assert page.steps.value() == 37
    assert page.seed.text() == "123456"
    assert page.prompt.toPlainText() == "Keep my current prompt"
    assert page.model.currentData() == str(model)
    assert page.generate_button.isEnabled()
    page._show_provider_folders()
    page._go_back_in_history()
    app.processEvents()
    assert calls == [1]