Private
Public Access
addet test set
This commit is contained in:
1 parent
d370f992ce
commit
b0f35bf3cc
21 files changed
+1796
-183
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;});
|
||||
Reference in new issue
Block a user