addet test set

This commit is contained in:
marcinator_2 committed 2026-10-14 17:40:31 +02:00
1 parent d370f992ce
commit b0f35bf3cc
21 files changed
+1796 -183

No files matched your search

+35
View File
@@ -24,3 +24,38 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
- `test_detection_model.py` prüft atomare/identische Installation und Ablehnung
falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine
geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft.
- `test_pi_update.py` prüft echte temporäre Versions-/Datenverzeichnisse mit
simuliertem systemd, Python-Paketsetup und HTTP-Ergebnis. Erfolgreichen Wechsel,
Start-/HTTP-/Abhängigkeitsfehler, Rückfall samt Daten, alte flache Installation,
Erstinstallation und Ablehnung fremder Units/manueller Server prüfen.
Kein SSH, kein echter Dienst, kein Netzwerk und keine Hardware in diesen Tests.
- `test_motion.py` nutzt synthetische JPEGs und optional OpenCV für echten
Optical Flow. Vorwärts/gegenläufig/parallel, Pfeilumkehr, gedrehte Geometrie,
Bildruhe/Lichtwechsel, Bereichsgrenzen und Bildlücken prüfen. Diensttests
prüfen Bestätigung, Sperrzeit, Stopp, Methodenmigration, Persistenz, verworfene
alte Inferenzfehler und modellfreie Verarbeitung. Ohne OpenCV werden die
Bildtests übersprungen; kein Ersatz für den Nahbereichstest am Pi.
- Nahbereichregression: kleiner 60x60-Bildausschnitt im 640x480-Frame,
2 Pixel Bewegung pro Bild; automatischen Trigger ohne manuelles Bereich-frei
und Ablehnung der Gegenrichtung prüfen.
- `node tests/test_ui_status.js` prüft den echten JS-Statusrenderer isoliert
ohne Browser/Netzwerk: automatische Freigabe vs. manuelle Bereich-frei-Flagge,
Vorrang von Stopp/Sequenz/Sperrzeit und wirkungslose Betriebsmodi.
- `test_event_images.py` prüft automatisch zugeordnete JPEGs mit synthetischem
Motiv: beschnittener privater Bereich, Dateirechte, Neustart, Migration alter
Ereignisse, DB-/Dateifehler, sichere ID-Abrufe, Header für Löschen und Erhalt
von Ereignis/Profil. Motion-Test prüft die exakten JPEG-Bytes des Trigger-Frames.
- `node tests/test_event_images_ui.js` prüft Vorschau/Ansehen und bestätigtes
Bildlöschen am echten JS-Renderer mit erfundenen Daten, ohne Browser oder Netz.
Abgebrochene Bestätigung löscht nichts; Ereignis bleibt nach Bildlöschung sichtbar.
- Update-Tests verlegen den festen Unitpfad nur in temporären Skriptkopien und
simulieren sudo/runuser. Zweiten sudo-Aufruf stets ablehnen, damit Erfolg und
Rückfall unabhängig von Passwort-Tickets geprüft werden. Abgelehnte Anmeldung,
unprivilegierten Aufruf des internen Rootteils und unveränderte Daten prüfen.
Kein echter root-/PAM-/systemd- oder Pi-Nachweis aus diesen Tests.
+2 -2
View File
@@ -200,7 +200,7 @@ class ServiceTest(unittest.TestCase):
def test_database_stall_does_not_block_watchdog(self):
entered, release = threading.Event(), threading.Event()
def blocked_record(mode):
def blocked_record(mode, **kwargs):
entered.set()
release.wait(3)
self.events.record_detection_trigger = blocked_record
@@ -220,7 +220,7 @@ class ServiceTest(unittest.TestCase):
self.assertFalse(worker.is_alive())
def test_database_failure_stops_simulation(self):
def broken_record(mode):
def broken_record(mode, **kwargs):
raise OSError('disk full')
self.events.record_detection_trigger = broken_record
self.free()
+137
View File
@@ -0,0 +1,137 @@
"""Local trigger pictures: synchronized crop, persistence, API and deletion."""
import asyncio
from io import BytesIO
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import httpx
from PIL import Image
from halloween.event_store import EventStore
from halloween.web import create_app
def sample():
image = Image.new('RGB', (100, 80), (20, 40, 180))
# Distinct central crop: outside the chosen area must not be recorded.
image.paste((180, 40, 20), (20, 16, 80, 64))
stream = BytesIO()
image.save(stream, 'JPEG', quality=95)
return stream.getvalue()
REGION = [.2, .2, .8, .8]
class StoreImageTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.root = Path(self.folder.name)
self.store = EventStore(self.root)
def test_automatic_kinds_persist_cropped_picture_after_restart(self):
for method in (self.store.record_detection_trigger, self.store.record_motion_trigger):
event_id = method('sanft', jpeg=sample(), region=REGION)
path = EventStore(self.root).image_path(event_id)
with Image.open(path) as image:
self.assertEqual(image.size, (60, 48))
self.assertEqual(image.format, 'JPEG')
self.assertGreater(image.getpixel((30, 24))[0], 150)
self.assertLess(image.getpixel((30, 24))[2], 50)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
event = self.store.recent()[0]
self.assertEqual(event['image_url'], f'/api/events/{event_id}/image')
self.assertNotIn('image_filename', event)
self.assertIsNone(event['profile_id'])
def test_delete_image_preserves_event_and_profile(self):
event_id = self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
profile = self.store.create_profile('Fiktiv')
self.store.assign_profile(event_id, profile['id'])
path = self.store.image_path(event_id)
self.assertTrue(self.store.delete_image(event_id))
self.assertFalse(path.exists())
event = EventStore(self.root).recent()[0]
self.assertIsNone(event['image_url'])
self.assertEqual(event['profile_id'], profile['id'])
self.assertFalse(self.store.delete_image(event_id))
with self.assertRaises(KeyError):
self.store.image_path(event_id)
def test_manual_events_and_migrated_old_events_have_no_picture(self):
event_id = self.store.record_test_trigger('sanft')
self.assertIsNone(self.store.recent()[0]['image_url'])
with self.assertRaises(KeyError):
self.store.image_path(event_id)
old_root = self.root / 'legacy'
old_root.mkdir()
with sqlite3.connect(old_root / 'events.sqlite3') as connection:
connection.execute('CREATE TABLE events (id INTEGER PRIMARY KEY, occurred_at TEXT, kind TEXT, mode TEXT)')
connection.execute("INSERT INTO events VALUES (1,'2026-10-14T10:00:00+00:00','test_trigger','sanft')")
old = EventStore(old_root)
self.assertIsNone(old.recent()[0]['image_url'])
self.assertEqual(old.recent()[0]['id'], 1)
new_id = old.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
self.assertTrue(old.image_path(new_id).exists())
def test_database_failure_removes_written_image(self):
with patch.object(self.store, '_connect', side_effect=sqlite3.OperationalError('synthetic DB failure')):
with self.assertRaises(sqlite3.Error):
self.store.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
self.assertEqual(list(self.store.image_dir.iterdir()), [])
self.assertEqual(self.store.recent(), [])
def test_image_failure_leaves_no_temp_file_or_database_event(self):
jpeg = sample()
with patch.object(Image.Image, 'save', side_effect=OSError('synthetic disk full')):
with self.assertRaises(OSError):
self.store.record_motion_trigger('sanft', jpeg=jpeg, region=REGION)
self.assertEqual(self.store.recent(), [])
if self.store.image_dir.exists():
self.assertEqual(list(self.store.image_dir.iterdir()), [])
def test_tampered_database_cannot_read_or_delete_outside_image_dir(self):
outside = self.root / 'keep.txt'
outside.write_text('keep')
event_id = self.store.record_test_trigger('sanft')
with sqlite3.connect(self.store.path) as connection:
connection.execute('UPDATE events SET image_filename = ? WHERE id = ?', ('../keep.txt', event_id))
with self.assertRaises(KeyError):
self.store.image_path(event_id)
with self.assertRaises(KeyError):
self.store.delete_image(event_id)
self.assertEqual(outside.read_text(), 'keep')
self.assertIsNone(self.store.recent()[0]['image_url'])
class ImageApiTest(unittest.TestCase):
def test_view_and_delete_image_without_affecting_event(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app = create_app(demo=True, state_dir=Path(folder))
event_id = app.state.events.record_motion_trigger('sanft', jpeg=sample(), region=REGION)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
url = (await client.get('/api/events')).json()['events'][0]['image_url']
image = await client.get(url)
self.assertEqual(image.status_code, 200)
self.assertEqual(image.headers['content-type'], 'image/jpeg')
self.assertEqual(image.headers['cache-control'], 'no-store')
self.assertEqual(image.headers['x-content-type-options'], 'nosniff')
self.assertEqual((await client.get('/api/events/999/image')).status_code, 404)
self.assertEqual((await client.delete(url)).status_code, 403)
self.assertEqual((await client.delete(url, headers={'X-Halloween-UI':'1'})).status_code, 200)
self.assertEqual((await client.get(url)).status_code, 404)
event = (await client.get('/api/events')).json()['events'][0]
self.assertEqual(event['id'], event_id)
self.assertIsNone(event['image_url'])
asyncio.run(scenario())
if __name__ == '__main__':
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
// Verify picture controls on real event-list renderer using fabricated records.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../halloween/index.html'), 'utf8');
const renderer = html.slice(html.indexOf(' async function refreshEvents() {'), html.indexOf(' async function refreshDb() {'));
class Element {
constructor(tag) {this.tag=tag; this.children=[]; this.textContent='';}
append(...children) {this.children.push(...children);}
prepend(...children) {this.children.unshift(...children);}
add(child) {this.append(child);}
setAttribute(name,value) {this[name]=value;}
replaceChildren() {this.children=[];}
}
(async () => {
const list = new Element('ul');
let allowDelete = false, deleted = false;
const calls = [];
const record = {id:1, occurred_at:'2026-10-14T10:00:00+00:00', kind:'motion_direction_test', mode:'sanft', profile_id:null};
const context = vm.createContext({
document:{createElement:tag => new Element(tag)},
$:id => id === 'events-list' ? list : new Element('p'),
profiles:[], names:{sanft:'Sanft'}, Option:class {},
confirm:() => allowDelete,
api:async (url, body, method) => {calls.push({url,method}); deleted=true;},
fetch:async () => ({ok:true, json:async () => ({events:[{...record,image_url:deleted ? null : '/api/events/1/image'}]})})
});
vm.runInContext(renderer, context);
await vm.runInContext('refreshEvents()', context);
let item = list.children[0];
const link = item.children.find(element => element.tag === 'a');
assert.equal(link.href, '/api/events/1/image');
assert.equal(link.textContent, 'Bild ansehen');
assert.equal(link.children[0].src, link.href);
assert.equal(link.children[0].loading, 'lazy');
const remove = item.children.find(element => element.textContent === 'Bild löschen');
await remove.onclick();
assert.equal(calls.length, 0, 'Cancel never deletes a picture');
allowDelete=true;
await remove.onclick();
assert.deepEqual(calls, [{url:'/api/events/1/image',method:'DELETE'}]);
item=list.children[0];
assert.equal(item.children.some(element => element.tag === 'a'), false);
assert.ok(item.children.some(element => element.textContent.includes('Kein gespeichertes Bild')));
assert.ok(item.children.some(element => element.textContent.includes('#1')));
console.log('Event image UI tests passed');
})().catch(error => {console.error(error); process.exitCode=1;});
+283
View File
@@ -0,0 +1,283 @@
"""Directional motion with synthetic images; no persons, model or Pi hardware."""
import asyncio
import importlib.util
from io import BytesIO
import json
from pathlib import Path
import tempfile
import threading
import unittest
import httpx
from PIL import Image
from halloween.controller import Controller
from halloween.detection import DEFAULT_CONFIG, DetectionService, validate_config
from halloween.event_store import EventStore
from halloween.motion import MotionDetector
from halloween.web import create_app
HAS_OPENCV = importlib.util.find_spec('cv2') is not None
CONFIG = {**DEFAULT_CONFIG, 'enabled': True, 'method': 'motion_test'}
def scene(x=85, y=35, brightness=0):
import numpy as np
rng = np.random.default_rng(91)
frame = np.full((180, 240), 45 + brightness, dtype=np.uint8)
texture = rng.integers(70, 210, size=(45, 50), dtype=np.uint8)
frame[y:y+45, x:x+50] = texture + brightness
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
def small_scene(x=210):
"""Small near-camera patch moving slowly in a full 640x480 frame."""
import numpy as np
rng = np.random.default_rng(121)
frame = np.full((480, 640), 95, dtype=np.uint8)
texture = rng.integers(110, 165, size=(60, 60), dtype=np.uint8)
frame[150:210, x:x+60] = texture
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
class ConfigurationTest(unittest.TestCase):
def test_old_config_retains_enabled_geometry_and_person_method(self):
old = {k: v for k, v in DEFAULT_CONFIG.items() if k != 'method'}
old['enabled'] = True
loaded = validate_config(old)
self.assertEqual(loaded['method'], 'person_line')
self.assertTrue(loaded['enabled'])
self.assertEqual(loaded['line'], old['line'])
self.assertNotIn('method', old)
def test_unknown_method_rejected(self):
with self.assertRaises(ValueError):
validate_config({**CONFIG, 'method': 'face'})
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class OpticalMotionTest(unittest.TestCase):
def detect_sequence(self, frames, config=CONFIG):
detector = MotionDetector()
return [detector.detect(frame, config, index*0.2) for index, frame in enumerate(frames)]
def test_forward_motion_without_line_crossing(self):
# Box stays entirely above the horizontal line at y=.55.
result = self.detect_sequence([scene(y=y) for y in (32, 36, 40, 44)])
self.assertFalse(result[0]['ready'])
self.assertFalse(result[1]['trigger'])
self.assertTrue(result[-1]['trigger'], result)
def test_reverse_and_parallel_do_not_trigger(self):
for frames in ([scene(y=y) for y in (44, 40, 36, 32)],
[scene(x=x) for x in (70, 74, 78, 82)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_arrow_can_be_reversed_or_rotated(self):
reverse = self.detect_sequence([scene(y=y) for y in (44, 40, 36, 32)],
{**CONFIG, 'direction': -1})
self.assertTrue(reverse[-1]['trigger'], reverse)
# Vertical line from bottom to top gives a rightward arrow.
rotated = self.detect_sequence([scene(x=x) for x in (70, 74, 78, 82)],
{**CONFIG, 'line': [[.55, .8], [.55, .2]]})
self.assertTrue(rotated[-1]['trigger'], rotated)
def test_static_scene_and_light_change_do_not_trigger(self):
for frames in ([scene()]*6, [scene(brightness=b) for b in (0, 25, 0, 25, 0)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
def test_motion_outside_region_is_ignored(self):
result = self.detect_sequence([scene(x=5, y=y) for y in (35, 39, 43, 47)],
{**CONFIG, 'region': [.3, .15, .9, .95]})
self.assertFalse(any(r['moving'] or r['trigger'] for r in result), result)
def test_small_slow_rightward_change_in_large_region(self):
result = self.detect_sequence([small_scene(x=x) for x in (210, 212, 214, 216, 218)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertTrue(result[-1]['trigger'], result)
def test_small_slow_reverse_change_does_not_trigger(self):
result = self.detect_sequence([small_scene(x=x) for x in (218, 216, 214, 212, 210)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_single_frame_or_camera_gap_cannot_trigger(self):
result = self.detect_sequence([scene(y=32), scene(y=36), scene(y=36)])
self.assertFalse(any(r['trigger'] for r in result), result)
detector = MotionDetector()
detector.detect(scene(y=32), CONFIG, 0)
detector.detect(scene(y=36), CONFIG, .2)
result = detector.detect(scene(y=40), CONFIG, 1.2)
self.assertFalse(result['ready'])
self.assertFalse(result['trigger'])
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class MotionServiceTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.directory = Path(self.folder.name)
self.time = 0
self.number = 0
self.controller = Controller(self.directory, clock=lambda: self.time)
self.events = EventStore(self.directory)
self.camera = type('Camera', (), {'error': None})()
self.service = DetectionService(self.camera, self.controller, self.events,
self.directory, clock=lambda: self.time)
self.service.configure(CONFIG)
self.detector = MotionDetector()
def step(self, x=85, y=35, dt=.2):
self.time += dt
self.number += 1
jpeg = scene(x, y)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
def quiet(self, y=35):
for _ in range(13):
self.step(y=y)
def move(self):
for y in (35, 39, 43, 47):
self.step(y=y)
def test_initial_quiet_cooldown_and_distinct_event(self):
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
self.assertTrue(self.service.status()['armed'])
self.move()
event = self.events.recent()[0]
self.assertEqual(event['kind'], 'motion_direction_test')
self.assertIsNone(event['profile_id'])
self.assertTrue(event['image_url'])
with Image.open(self.events.image_path(event['id'])) as saved:
self.assertEqual(saved.size, (192, 144))
expected = Image.open(BytesIO(scene(y=43))).crop((24, 27, 216, 171)).convert('RGB')
self.assertEqual(saved.size, expected.size)
encoded = BytesIO()
expected.save(encoded, format='JPEG', quality=90)
self.assertEqual(self.events.image_path(event['id']).read_bytes(), encoded.getvalue())
self.assertEqual(self.service.snapshot()['people'], [])
self.assertTrue(self.service.snapshot()['motion']['directed'])
self.quiet()
self.move()
self.assertEqual(len(self.events.recent()), 1)
self.quiet()
# Wait out remaining cooldown with valid quiet frames.
for _ in range(30):
self.step()
self.move()
self.assertEqual(len(self.events.recent()), 2)
def test_small_motion_triggers_without_manual_clear(self):
self.service.configure({**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.detector = MotionDetector()
def step(x):
self.time += .2
self.number += 1
jpeg = small_scene(x)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
for _ in range(13):
step(210)
self.assertTrue(self.service.status()['armed'])
self.assertFalse(self.controller.zone_clear_seen)
for x in (212, 214, 216):
step(x)
self.assertEqual(len(self.events.recent()), 1)
self.assertEqual(self.events.recent()[0]['kind'], 'motion_direction_test')
self.assertEqual(self.service.snapshot()['motion']['confirmed_frames'], 2)
def test_stop_watchdog_and_stale_inflight_frame(self):
self.quiet()
self.controller.stop()
self.move()
self.assertEqual(self.events.recent(), [])
self.controller.reset()
self.service.reset()
self.detector = MotionDetector()
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
old_revision = self.service.revision
self.service.configure(CONFIG)
self.service.process([], 99, scene(), self.time, old_revision,
{'ready': True, 'moving': True, 'trigger': True})
self.assertIsNone(self.service.last_valid)
self.time += 2.1
self.service.watchdog()
self.assertTrue(self.controller.stopped)
self.assertTrue(self.service.status()['reset_required'])
def test_restart_preserves_method_but_not_arming(self):
self.quiet()
other = DetectionService(self.camera, self.controller, self.events, self.directory)
self.assertEqual(other.get_config()['method'], 'motion_test')
self.assertTrue(other.get_config()['enabled'])
self.assertFalse(other.status()['armed'])
def test_switch_discards_old_model_failure(self):
entered, release = threading.Event(), threading.Event()
self.service.configure({**DEFAULT_CONFIG, 'enabled': False})
def old_model(path):
entered.set()
release.wait(3)
raise OSError('old model failure')
self.service.factory = old_model
self.camera.latest_timed = lambda: (1, scene(), self.time)
self.service.start()
try:
self.assertTrue(entered.wait(1))
self.service.configure(CONFIG)
release.set()
for _ in range(40):
if self.service.last_valid is not None:
break
threading.Event().wait(.05)
self.assertIsNotNone(self.service.last_valid)
self.assertFalse(self.service.fault)
self.assertFalse(self.controller.stopped)
finally:
release.set()
self.service.stop()
def test_worker_uses_motion_without_person_model(self):
async def scenario():
app = create_app(demo=True, state_dir=self.directory)
app.state.detection.configure(CONFIG)
def no_person_model(path):
raise AssertionError('Person model must not be loaded for motion test')
app.state.detection.factory = no_person_model
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
for _ in range(30):
await asyncio.sleep(.05)
result = (await client.get('/api/detection')).json()
if result['motion'] and result['motion']['ready']:
break
self.assertTrue(result['status']['ready'], result)
self.assertEqual(result['status']['method'], 'motion_test')
self.assertFalse(result['status']['reset_required'], result)
self.assertIsNotNone(result['motion'])
self.assertEqual(result['people'], [])
asyncio.run(scenario())
if __name__ == '__main__':
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""Filesystem deployment tests with simulated systemd, Python setup and health.
No SSH, package downloads, real services or Pi hardware are used.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PROJECT = Path(__file__).resolve().parents[1]
class PiUpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='halloween-update-')
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.root = self.base / 'pi'
self.bin = self.base / 'bin'
self.bin.mkdir()
self.root.mkdir()
(self.root / 'data').mkdir()
(self.root / 'data/settings.json').write_text('original settings')
(self.root / 'data/events.sqlite3').write_bytes(b'original database')
self.new = self.root / 'releases/new'
self.new.mkdir(parents=True)
shutil.copytree(PROJECT / 'tools', self.new / 'tools')
(self.new / 'halloween').mkdir()
for file in ('run.py', 'requirements.txt'):
shutil.copy(PROJECT / file, self.new / file)
self.old = self.root / 'releases/old'
self.old.mkdir()
(self.old / 'run.py').write_text('# old version')
(self.root / 'current').symlink_to(self.old)
self.unit = self.base / 'unit'
self.unit.write_text('# Managed by Halloween project: tools/install_pi_service.sh\nold unit\n')
self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}',
TEST_BASE=str(self.base), TEST_ROOT=str(self.root))
# Relocate the fixed unit path only in temporary test copies. No root
# commands run and no host /etc files are ever accessed by these tests.
for script in ('activate_pi_release.sh', 'install_pi_service.sh'):
file = self.new / 'tools' / script
contents = file.read_text()
contents = contents.replace('unit=/etc/systemd/system/$service', f'unit={self.unit}')
contents = contents.replace('unit_path="/etc/systemd/system/$service_name"', f'unit_path="{self.unit}"')
file.write_text(contents)
self.script('id', '''
if [[ $1 == -u ]]; then
if [[ ${TEST_PRIVILEGED:-0} == 1 ]]; then echo 0; else echo 1000; fi
else
echo piuser
fi
''')
self.env['TEST_SUDO_UID'] = str(os.getuid())
self.env['TEST_SUDO_GID'] = str(os.getgid())
self.script('sudo', '''
echo called >> "$TEST_BASE/sudo.log"
if [[ -f $TEST_BASE/deny-sudo ]]; then exit 1; fi
# Fail a second sudo call, simulating an expired/zero-lifetime ticket.
if [[ $(wc -l < "$TEST_BASE/sudo.log") != 1 ]]; then exit 1; fi
export TEST_PRIVILEGED=1 SUDO_USER=piuser SUDO_UID="$TEST_SUDO_UID" SUDO_GID="$TEST_SUDO_GID"
exec "$@"
''')
self.script('runuser', '''
[[ $1 == -u && $2 == piuser && $3 == -- ]] || exit 2
shift 3
export TEST_PRIVILEGED=0
exec "$@"
''')
self.script('systemctl', '''
echo "$*" >> "$TEST_BASE/systemctl.log"
case $1 in
is-enabled|is-active|daemon-reload|enable|disable|stop) exit 0 ;;
restart)
if [[ -f $TEST_BASE/fail-start && $(readlink "$TEST_ROOT/current") == */new ]]; then
echo changed > "$TEST_ROOT/data/events.sqlite3"
exit 1
fi ;;
esac
''')
self.script('python3', '''
if [[ $1 != -m || $2 != venv ]]; then exit 2; fi
mkdir -p "$4/bin"
cp "$TEST_BASE/bin/fake-python" "$4/bin/python"
''')
self.script('fake-python', '''
if [[ $1 == -m && $2 == pip && -f $TEST_BASE/fail-deps ]]; then exit 1; fi
if [[ $1 == - ]]; then
code=$(cat)
if [[ $code == *connect_ex* && -f $TEST_BASE/port-busy ]]; then exit 1; fi
if [[ $code == *urlopen* && -f $TEST_BASE/fail-all-health ]]; then exit 1; fi
if [[ $code == *urlopen* && -f $TEST_BASE/fail-health && $(readlink "$TEST_ROOT/current") == */new ]]; then
echo migrated > "$TEST_ROOT/data/events.sqlite3"
exit 1
fi
fi
exit 0
''')
def script(self, name, content):
path = self.bin / name
path.write_text('#!/usr/bin/env bash\nset -e\n' + content + '\n')
path.chmod(0o755)
def activate(self):
return subprocess.run(['bash', str(self.new / 'tools/activate_pi_release.sh'),
str(self.root), 'new', '0.0.0.0'],
env=self.env, capture_output=True, text=True)
def assert_single_sudo(self):
self.assertEqual((self.base / 'sudo.log').read_text().splitlines(), ['called'])
def assert_data(self):
self.assertEqual((self.root / 'data/settings.json').read_text(), 'original settings')
self.assertEqual((self.root / 'data/events.sqlite3').read_bytes(), b'original database')
def test_success_preserves_data_and_enables_autostart(self):
result = self.activate()
self.assertEqual(result.returncode, 0, result.stderr)
self.assert_single_sudo()
self.assertEqual((self.root / 'current').resolve(), self.new)
self.assert_data()
self.assertIn(f'Environment=HALLOWEEN_STATE_DIR={self.root}/data', self.unit.read_text())
self.assertIn('--host 0.0.0.0', self.unit.read_text())
self.assertIn('enable halloween-grab.service', (self.base / 'systemctl.log').read_text())
def test_start_and_health_failure_restore_code_unit_and_data(self):
for failure in ('fail-start', 'fail-health'):
with self.subTest(failure=failure):
(self.base / failure).touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertIn('old unit', self.unit.read_text())
self.assertIn('Rückfall abgeschlossen', result.stderr)
self.assert_single_sudo()
(self.base / failure).unlink()
shutil.rmtree(self.root / 'backups/new')
(self.base / 'sudo.log').unlink()
def test_dependency_failure_leaves_service_running(self):
(self.base / 'fail-deps').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertFalse((self.base / 'systemctl.log').exists())
self.assertFalse((self.base / 'sudo.log').exists())
def test_first_install_failure_has_no_previous_version(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.base / 'fail-health').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.unit.exists())
self.assertFalse((self.root / 'current').is_symlink())
self.assert_data()
def test_manual_server_blocks_first_install_without_stopping_it(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.base / 'port-busy').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_data()
def test_flat_installation_is_available_for_rollback(self):
self.unit.unlink()
(self.root / 'current').unlink()
(self.root / 'run.py').write_text('# legacy version')
(self.root / '.venv/bin').mkdir(parents=True)
shutil.copy(self.bin / 'fake-python', self.root / '.venv/bin/python')
(self.base / 'fail-start').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.root)
self.assertIn(str(self.root / 'current/.venv/bin/python'), self.unit.read_text())
self.assert_data()
self.assertIn('Rückfall abgeschlossen', result.stderr)
def test_first_install_success(self):
self.unit.unlink()
(self.root / 'current').unlink()
result = self.activate()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'current').resolve(), self.new)
self.assert_data()
def test_failed_rollback_health_is_reported(self):
(self.base / 'fail-all-health').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertIn('Rückfall unvollständig', result.stderr)
self.assert_data()
def test_upload_preview_never_connects(self):
for name in ('ssh', 'rsync'):
self.script(name, 'echo called > "$TEST_BASE/connected"; exit 1')
# Use real Python for the local release ID; no Pi package setup in preview.
(self.bin / 'python3').unlink()
result = subprocess.run(['bash', str(PROJECT / 'tools/upload_to_pi.sh'),
'--user', 'piuser', '--host', 'pi.local',
'--remote-dir', '/home/piuser/halloween'],
env=self.env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.base / 'connected').exists())
self.assertIn('releases/', result.stdout)
def test_denied_sudo_never_stops_service_or_changes_active_data(self):
(self.base / 'deny-sudo').touch()
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'current').resolve(), self.old)
self.assert_data()
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_single_sudo()
def test_privileged_entry_rejects_direct_unprivileged_call(self):
result = subprocess.run(['bash', str(self.new / 'tools/activate_pi_release.sh'),
'--privileged', str(self.root), 'new', '0.0.0.0'],
env=self.env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.base / 'systemctl.log').exists())
self.assert_data()
def test_foreign_unit_is_untouched(self):
self.unit.write_text('foreign unit')
result = self.activate()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.unit.read_text(), 'foreign unit')
self.assertEqual((self.root / 'current').resolve(), self.old)
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
// Exercise the real status renderer without a browser, network or camera.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../halloween/index.html'), 'utf8');
const detectionRefresh = html.slice(html.indexOf(' async function refreshDetection() {'), html.indexOf(' function updateDetectionHelp()'));
const refresh = html.slice(html.indexOf(' async function refresh() {'), html.indexOf(' async function action('));
async function render(controller, detection) {
const nodes = {};
const context = vm.createContext({
document: {activeElement: null},
$: id => nodes[id] || (nodes[id] = {}),
names: {sanft: 'Sanft'},
fetch: async () => ({json: async () => ({controller, detection, camera: {ready:true, source:'Test'}})})
});
vm.runInContext(refresh, context);
await vm.runInContext('refresh()', context);
return nodes;
}
(async () => {
const controller = {mode:'sanft', phase:'Bereich frei abwarten', cooldown_seconds:0};
const ready = {enabled:true, ready:true, armed:true, method:'motion_test'};
let nodes = await render(controller, ready);
assert.equal(nodes.phase.textContent, 'Bereit (Automatik)', 'Automatic ready does not need manual clear');
nodes = await render({...controller, phase:'Bereit'}, {...ready, armed:false});
assert.equal(nodes.phase.textContent, 'Ruhiges Bild abwarten (Automatik)', 'Manual clear cannot claim automatic ready');
for (const phase of ['Stopp', 'Sperrzeit', 'Sanft (simuliert)']) {
nodes = await render({...controller, phase}, ready);
assert.equal(nodes.phase.textContent, phase, 'Preserve actual sequence/stop/cooldown');
}
nodes = await render({...controller, mode:'aus'}, ready);
assert.equal(nodes.phase.textContent, 'Modus ohne Schreckauslösung');
nodes = await render(controller, {...ready, reset_required:true});
assert.equal(nodes.phase.textContent, 'Erkennung gesperrt; zurücksetzen');
nodes = await render(controller, {...ready, enabled:false});
assert.equal(nodes.phase.textContent, controller.phase, 'Manual mode retains its own clear requirement');
const motionNodes = {};
const motionContext = vm.createContext({
$: id => motionNodes[id] || (motionNodes[id] = {}),
detectionEpoch:0, performance:{now:() => 0}, people:[], processed:false,
setTimeout:() => {}, drawDetection:() => {},
fetch:async () => ({ok:true, json:async () => ({
status:{...ready, fps:5}, image_base64:null,
motion:{changed_fraction:.0014, note:'Bewegung in Pfeilrichtung', confirmed_frames:2, moving:true, directed:true}
})})
});
vm.runInContext(detectionRefresh, motionContext);
await vm.runInContext('refreshDetection()', motionContext);
assert.equal(motionNodes['motion-details'].hidden, false);
assert.match(motionNodes['motion-details'].textContent, /0,14 %/);
assert.match(motionNodes['motion-details'].textContent, /Bewegung in Pfeilrichtung/);
assert.match(motionNodes['motion-details'].textContent, /Bestätigung 2\/2/);
console.log('UI status tests passed');
})().catch(error => {console.error(error); process.exitCode = 1;});