mirror of https://github.com/razor-ai/soup.git
368 lines
10 KiB
Python
368 lines
10 KiB
Python
"""v0.44.0 Part B — UX fix tests.
|
|
|
|
Covers: graceful_save (Ctrl+C SIGINT), checkpoint_trigger, shortcuts,
|
|
onboarding wizard.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from soup_cli.utils.checkpoint_trigger import (
|
|
TRIGGER_FILENAME,
|
|
consume_trigger,
|
|
should_save_now,
|
|
trigger_path,
|
|
write_trigger,
|
|
)
|
|
from soup_cli.utils.graceful_save import GracefulSaveHandler
|
|
from soup_cli.utils.onboarding import (
|
|
VALID_QUANT,
|
|
VALID_TASKS,
|
|
render_onboarding_yaml,
|
|
)
|
|
from soup_cli.utils.shortcuts import (
|
|
build_for_current_platform,
|
|
build_linux_desktop_entry,
|
|
build_macos_command_file,
|
|
build_windows_cmd,
|
|
detect_platform,
|
|
)
|
|
|
|
# --- graceful_save (SIGINT handler) -----------------------------------------
|
|
|
|
class _FakeState:
|
|
should_save = False
|
|
should_training_stop = False
|
|
|
|
|
|
def test_graceful_save_first_sigint_triggers_save():
|
|
handler = GracefulSaveHandler()
|
|
state = _FakeState()
|
|
handler.attach_state(state)
|
|
handler._handle_sigint(2, None)
|
|
assert state.should_save is True
|
|
assert state.should_training_stop is False
|
|
|
|
|
|
def test_graceful_save_second_sigint_stops_training():
|
|
handler = GracefulSaveHandler()
|
|
state = _FakeState()
|
|
handler.attach_state(state)
|
|
handler._handle_sigint(2, None)
|
|
handler._handle_sigint(2, None)
|
|
assert state.should_training_stop is True
|
|
|
|
|
|
def test_graceful_save_no_state_first_signal_keyboardinterrupt():
|
|
handler = GracefulSaveHandler()
|
|
# No state attached → 1st signal raises since count==1 and state is None.
|
|
with pytest.raises(KeyboardInterrupt):
|
|
handler._handle_sigint(2, None)
|
|
|
|
|
|
def test_graceful_save_install_idempotent(monkeypatch):
|
|
import signal as _signal
|
|
|
|
calls = []
|
|
|
|
def fake_signal(signum, handler):
|
|
calls.append(signum)
|
|
return _signal.SIG_DFL
|
|
|
|
monkeypatch.setattr(_signal, "signal", fake_signal)
|
|
handler = GracefulSaveHandler()
|
|
handler.install()
|
|
handler.install()
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_graceful_save_state_attribute_missing_does_not_crash():
|
|
handler = GracefulSaveHandler()
|
|
|
|
class _Empty:
|
|
pass
|
|
|
|
handler.attach_state(_Empty())
|
|
# First SIGINT: should_save assignment AttributeError swallowed.
|
|
handler._handle_sigint(2, None)
|
|
|
|
|
|
# --- checkpoint_trigger -----------------------------------------------------
|
|
|
|
def test_trigger_path_under_cwd(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
path = trigger_path(str(out))
|
|
assert path.endswith(TRIGGER_FILENAME)
|
|
|
|
|
|
def test_trigger_path_outside_cwd_rejected(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
other = tmp_path.parent / "definitely-not-under-cwd"
|
|
with pytest.raises(ValueError, match="outside cwd"):
|
|
trigger_path(str(other))
|
|
|
|
|
|
def test_trigger_path_rejects_null_byte(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError):
|
|
trigger_path("./out\x00bad")
|
|
|
|
|
|
def test_trigger_path_rejects_empty():
|
|
with pytest.raises(ValueError):
|
|
trigger_path("")
|
|
|
|
|
|
def test_trigger_path_rejects_non_string():
|
|
with pytest.raises(TypeError):
|
|
trigger_path(123) # type: ignore[arg-type]
|
|
|
|
|
|
def test_should_save_now_false_when_missing(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
assert should_save_now(str(out)) is False
|
|
|
|
|
|
def test_write_and_consume_trigger(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
write_trigger(str(out))
|
|
assert should_save_now(str(out)) is True
|
|
assert consume_trigger(str(out)) is True
|
|
assert should_save_now(str(out)) is False
|
|
# Consuming a non-existent trigger returns False, not an error.
|
|
assert consume_trigger(str(out)) is False
|
|
|
|
|
|
def test_write_trigger_rejects_null_byte_contents(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
with pytest.raises(ValueError):
|
|
write_trigger(str(out), contents="bad\x00byte")
|
|
|
|
|
|
def test_write_trigger_rejects_non_string_contents(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
with pytest.raises(TypeError):
|
|
write_trigger(str(out), contents=123) # type: ignore[arg-type]
|
|
|
|
|
|
def test_should_save_now_false_for_invalid_input():
|
|
assert should_save_now("") is False
|
|
assert should_save_now(123) is False # type: ignore[arg-type]
|
|
|
|
|
|
# --- shortcuts --------------------------------------------------------------
|
|
|
|
def test_detect_platform_known():
|
|
assert detect_platform() in {"linux", "darwin", "windows", "unknown"}
|
|
|
|
|
|
def test_build_linux_desktop_entry():
|
|
spec = build_linux_desktop_entry(name="Soup CLI", command="soup ui")
|
|
assert spec.platform == "linux"
|
|
assert spec.filename == "soup-cli.desktop"
|
|
assert "Exec=soup ui" in spec.content
|
|
assert "Categories=Development;" in spec.content
|
|
|
|
|
|
def test_build_macos_command_file():
|
|
spec = build_macos_command_file(name="Soup", command="soup ui")
|
|
assert spec.platform == "darwin"
|
|
assert spec.filename.endswith(".command")
|
|
assert spec.content.startswith("#!/usr/bin/env bash")
|
|
|
|
|
|
def test_build_windows_cmd():
|
|
spec = build_windows_cmd(name="Soup", command="soup ui")
|
|
assert spec.platform == "windows"
|
|
assert spec.filename.endswith(".cmd")
|
|
assert spec.content.startswith("@echo off")
|
|
|
|
|
|
def test_shortcut_rejects_disallowed_chars_in_name():
|
|
with pytest.raises(ValueError):
|
|
build_linux_desktop_entry(name="bad`name", command="x")
|
|
|
|
|
|
def test_shortcut_rejects_control_in_command():
|
|
with pytest.raises(ValueError):
|
|
build_linux_desktop_entry(name="ok", command="x\nrm -rf /")
|
|
|
|
|
|
def test_shortcut_rejects_empty_command():
|
|
with pytest.raises(ValueError):
|
|
build_linux_desktop_entry(name="ok", command="")
|
|
|
|
|
|
def test_shortcut_rejects_oversize_name():
|
|
with pytest.raises(ValueError):
|
|
build_linux_desktop_entry(name="x" * 100, command="echo")
|
|
|
|
|
|
def test_shortcut_rejects_non_string_name():
|
|
with pytest.raises(TypeError):
|
|
build_linux_desktop_entry(name=123, command="x") # type: ignore[arg-type]
|
|
|
|
|
|
def test_build_for_current_platform_returns_a_spec():
|
|
plat = detect_platform()
|
|
if plat == "unknown":
|
|
with pytest.raises(NotImplementedError):
|
|
build_for_current_platform(name="Soup", command="soup ui")
|
|
else:
|
|
spec = build_for_current_platform(name="Soup", command="soup ui")
|
|
assert spec.platform == plat
|
|
|
|
|
|
# --- onboarding wizard ------------------------------------------------------
|
|
|
|
def test_render_onboarding_yaml_happy_path():
|
|
text = render_onboarding_yaml(
|
|
{
|
|
"base": "meta-llama/Llama-3.2-1B",
|
|
"dataset": "./train.jsonl",
|
|
"task": "sft",
|
|
"quantization": "4bit",
|
|
"epochs": 3,
|
|
}
|
|
)
|
|
cfg = yaml.safe_load(text)
|
|
assert cfg["base"] == "meta-llama/Llama-3.2-1B"
|
|
assert cfg["task"] == "sft"
|
|
assert cfg["training"]["epochs"] == 3
|
|
assert cfg["training"]["quantization"] == "4bit"
|
|
|
|
|
|
def test_render_onboarding_yaml_default_quant_and_output():
|
|
text = render_onboarding_yaml(
|
|
{
|
|
"base": "x/y",
|
|
"dataset": "./d.jsonl",
|
|
"task": "dpo",
|
|
"epochs": 1,
|
|
}
|
|
)
|
|
cfg = yaml.safe_load(text)
|
|
assert cfg["training"]["quantization"] == "4bit"
|
|
assert cfg["output"] == "./out"
|
|
assert cfg["training"]["batch_size"] == "auto"
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_unknown_task():
|
|
with pytest.raises(ValueError, match="task must be"):
|
|
render_onboarding_yaml(
|
|
{"base": "x/y", "dataset": "d", "task": "bogus", "epochs": 1}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_unknown_quant():
|
|
with pytest.raises(ValueError, match="quantization"):
|
|
render_onboarding_yaml(
|
|
{
|
|
"base": "x/y",
|
|
"dataset": "d",
|
|
"task": "sft",
|
|
"quantization": "16bit",
|
|
"epochs": 1,
|
|
}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_bad_epochs():
|
|
with pytest.raises(ValueError):
|
|
render_onboarding_yaml(
|
|
{"base": "x/y", "dataset": "d", "task": "sft", "epochs": 0}
|
|
)
|
|
with pytest.raises(ValueError):
|
|
render_onboarding_yaml(
|
|
{"base": "x/y", "dataset": "d", "task": "sft", "epochs": 99}
|
|
)
|
|
with pytest.raises(TypeError):
|
|
render_onboarding_yaml(
|
|
{"base": "x/y", "dataset": "d", "task": "sft", "epochs": True}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_null_byte():
|
|
with pytest.raises(ValueError):
|
|
render_onboarding_yaml(
|
|
{
|
|
"base": "x\x00/y",
|
|
"dataset": "d",
|
|
"task": "sft",
|
|
"epochs": 1,
|
|
}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_oversize_base():
|
|
with pytest.raises(ValueError):
|
|
render_onboarding_yaml(
|
|
{
|
|
"base": "a" * 1000,
|
|
"dataset": "d",
|
|
"task": "sft",
|
|
"epochs": 1,
|
|
}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_explicit_batch_size():
|
|
text = render_onboarding_yaml(
|
|
{
|
|
"base": "x/y",
|
|
"dataset": "d",
|
|
"task": "sft",
|
|
"epochs": 1,
|
|
"batch_size": 8,
|
|
}
|
|
)
|
|
cfg = yaml.safe_load(text)
|
|
assert cfg["training"]["batch_size"] == 8
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_bool_batch_size():
|
|
with pytest.raises(TypeError):
|
|
render_onboarding_yaml(
|
|
{
|
|
"base": "x/y",
|
|
"dataset": "d",
|
|
"task": "sft",
|
|
"epochs": 1,
|
|
"batch_size": True,
|
|
}
|
|
)
|
|
|
|
|
|
def test_render_onboarding_yaml_rejects_non_dict():
|
|
with pytest.raises(TypeError):
|
|
render_onboarding_yaml("not a dict") # type: ignore[arg-type]
|
|
|
|
|
|
def test_onboarding_constants_align():
|
|
# Sanity: constants exported don't drift.
|
|
assert "sft" in VALID_TASKS
|
|
assert "preference" in VALID_TASKS
|
|
assert "4bit" in VALID_QUANT
|
|
assert "none" in VALID_QUANT
|
|
|
|
|
|
def test_path_exists(tmp_path, monkeypatch):
|
|
"""Sanity: the test temp dir is real and matches Path semantics."""
|
|
monkeypatch.chdir(tmp_path)
|
|
assert os.path.isdir(Path.cwd())
|