@@ -393,5 +393,155 @@ def test_server_url_arg_passed(self, mock_cli_cls):
393393 self .assertEqual (call_kwargs .kwargs .get ("server_url" ), "http://myhost:9000" )
394394
395395
396+ class _CursorMockHandler (http .server .BaseHTTPRequestHandler ):
397+ """Mock HTTP handler that respects raw_since query parameter."""
398+
399+ # Simulated log entries: list of {"id": int, "data": str}
400+ log_entries = []
401+
402+ def do_GET (self ):
403+ from urllib .parse import urlparse , parse_qs
404+
405+ parsed = urlparse (self .path )
406+ path = parsed .path
407+ qs = parse_qs (parsed .query )
408+
409+ if path == "/api/status" :
410+ body = json .dumps (
411+ {"success" : True , "connected" : True , "port" : "/dev/ttyACM0" }
412+ ).encode ()
413+ self .send_response (200 )
414+ self .send_header ("Content-Type" , "application/json" )
415+ self .end_headers ()
416+ self .wfile .write (body )
417+ elif path == "/api/logs" :
418+ raw_since = int (qs .get ("raw_since" , [0 ])[0 ])
419+ entries = [e for e in self .log_entries if e ["id" ] >= raw_since ]
420+ raw_data = "" .join (e ["data" ] for e in entries )
421+ raw_next = (
422+ max (e ["id" ] for e in self .log_entries ) + 1 if self .log_entries else 0
423+ )
424+ body = json .dumps ({"raw_data" : raw_data , "raw_next" : raw_next }).encode ()
425+ self .send_response (200 )
426+ self .send_header ("Content-Type" , "application/json" )
427+ self .end_headers ()
428+ self .wfile .write (body )
429+ else :
430+ self .send_response (404 )
431+ self .end_headers ()
432+
433+ def do_POST (self ):
434+ content_length = int (self .headers .get ("Content-Length" , 0 ))
435+ self .rfile .read (content_length ) if content_length else b""
436+ self .do_GET ()
437+
438+ def log_message (self , format , * args ):
439+ pass
440+
441+
442+ class TestSerialReadSinceCursor (unittest .TestCase ):
443+ """Test serial_read --since cursor for incremental reads."""
444+
445+ @classmethod
446+ def setUpClass (cls ):
447+ _CursorMockHandler .log_entries = [
448+ {"id" : 0 , "data" : "line0\n " },
449+ {"id" : 1 , "data" : "line1\n " },
450+ {"id" : 2 , "data" : "line2\n " },
451+ ]
452+ cls .server = http .server .HTTPServer (("127.0.0.1" , 0 ), _CursorMockHandler )
453+ cls .port = cls .server .server_address [1 ]
454+ cls .server_url = f"http://127.0.0.1:{ cls .port } "
455+ cls .server_thread = threading .Thread (target = cls .server .serve_forever )
456+ cls .server_thread .daemon = True
457+ cls .server_thread .start ()
458+
459+ @classmethod
460+ def tearDownClass (cls ):
461+ cls .server .shutdown ()
462+
463+ def _make_cli (self ):
464+ return FPBCLI (port = "/dev/ttyACM0" , server_url = self .server_url )
465+
466+ def test_serial_read_since_zero_returns_all (self ):
467+ """serial_read(since=0) returns all log entries."""
468+ cli = self ._make_cli ()
469+ buf = io .StringIO ()
470+ with redirect_stdout (buf ):
471+ cli .serial_read (since = 0 )
472+ result = json .loads (buf .getvalue ())
473+ self .assertTrue (result ["success" ])
474+ self .assertIn ("line0" , result ["raw_data" ])
475+ self .assertIn ("line1" , result ["raw_data" ])
476+ self .assertIn ("line2" , result ["raw_data" ])
477+ self .assertEqual (result ["raw_next" ], 3 )
478+ cli .cleanup ()
479+
480+ def test_serial_read_since_skips_old (self ):
481+ """serial_read(since=2) returns only entries with id >= 2."""
482+ cli = self ._make_cli ()
483+ buf = io .StringIO ()
484+ with redirect_stdout (buf ):
485+ cli .serial_read (since = 2 )
486+ result = json .loads (buf .getvalue ())
487+ self .assertTrue (result ["success" ])
488+ self .assertNotIn ("line0" , result ["raw_data" ])
489+ self .assertNotIn ("line1" , result ["raw_data" ])
490+ self .assertIn ("line2" , result ["raw_data" ])
491+ self .assertEqual (result ["raw_next" ], 3 )
492+ cli .cleanup ()
493+
494+ def test_serial_read_since_beyond_returns_empty (self ):
495+ """serial_read(since=raw_next) returns empty raw_data."""
496+ cli = self ._make_cli ()
497+ buf = io .StringIO ()
498+ with redirect_stdout (buf ):
499+ cli .serial_read (since = 3 )
500+ result = json .loads (buf .getvalue ())
501+ self .assertTrue (result ["success" ])
502+ self .assertEqual (result ["raw_data" ], "" )
503+ self .assertEqual (result ["raw_next" ], 3 )
504+ cli .cleanup ()
505+
506+ def test_serial_read_raw_next_in_output (self ):
507+ """serial_read output always contains raw_next field."""
508+ cli = self ._make_cli ()
509+ buf = io .StringIO ()
510+ with redirect_stdout (buf ):
511+ cli .serial_read ()
512+ result = json .loads (buf .getvalue ())
513+ self .assertIn ("raw_next" , result )
514+ self .assertIsInstance (result ["raw_next" ], int )
515+ cli .cleanup ()
516+
517+ def test_serial_read_incremental_workflow (self ):
518+ """Simulate incremental read: first read all, then only new."""
519+ cli = self ._make_cli ()
520+
521+ # First read: get everything
522+ buf = io .StringIO ()
523+ with redirect_stdout (buf ):
524+ cli .serial_read (since = 0 )
525+ r1 = json .loads (buf .getvalue ())
526+ cursor = r1 ["raw_next" ]
527+ self .assertEqual (cursor , 3 )
528+
529+ # Add new entry
530+ _CursorMockHandler .log_entries .append ({"id" : 3 , "data" : "line3\n " })
531+
532+ # Second read: only new data
533+ buf = io .StringIO ()
534+ with redirect_stdout (buf ):
535+ cli .serial_read (since = cursor )
536+ r2 = json .loads (buf .getvalue ())
537+ self .assertIn ("line3" , r2 ["raw_data" ])
538+ self .assertNotIn ("line0" , r2 ["raw_data" ])
539+ self .assertEqual (r2 ["raw_next" ], 4 )
540+
541+ # Restore
542+ _CursorMockHandler .log_entries .pop ()
543+ cli .cleanup ()
544+
545+
396546if __name__ == "__main__" :
397547 unittest .main ()
0 commit comments