@@ -630,69 +630,113 @@ def fake_emit(m):
630630class TestRequiredSlotsBackstop (unittest .TestCase ):
631631 """OVOS-PIPELINE-1 §6.2 orchestrator backstop for required_slots."""
632632
633+ SKILL_ID = "music.skill"
634+ INTENT_NAME = "play_music"
635+ MATCH_TYPE = f"{ SKILL_ID } :{ INTENT_NAME } "
636+
637+ def setUp (self ):
638+ self .svc = _make_service ()
639+
640+ def _register_template (self , skill_id = None , intent_name = None ,
641+ required_slots = None ):
642+ """Register a template intent on the manifest (INTENT-4 §6.1)."""
643+ msg = Message (
644+ "ovos.intent.register.template" ,
645+ {
646+ "skill_id" : skill_id or self .SKILL_ID ,
647+ "intent_name" : intent_name or self .INTENT_NAME ,
648+ "lang" : "en-US" ,
649+ "samples" : ["play {query}" ],
650+ "required_slots" : required_slots or [],
651+ },
652+ {"skill_id" : skill_id or self .SKILL_ID },
653+ )
654+ self .svc .intent_manifest ._on_register (msg )
655+
633656 @staticmethod
634- def _match_with_required (present_slots , required_slots ):
635- data = {"skill_id" : "music.skill" }
636- data .update (present_slots )
637- data ["__required_slots__" ] = required_slots
657+ def _make_match (slot_map = None , skill_id = SKILL_ID , match_type = MATCH_TYPE ):
658+ """Create a match with given slot map (no __required_slots__)."""
659+ data = {"skill_id" : skill_id }
660+ if slot_map :
661+ data .update (slot_map )
638662 return IntentHandlerMatch (
639- match_type = "music.skill:play_music" ,
663+ match_type = match_type ,
640664 match_data = data ,
641- skill_id = "music.skill" ,
665+ skill_id = skill_id ,
642666 utterance = "play some jazz" ,
643667 )
644668
645669 # --- _missing_required_slots unit ---------------------------------------
646670
647671 def test_no_required_slots_is_noop (self ):
648- """A match that carries no required_slots is never filtered ."""
672+ """No registered required_slots → backstop is a no-op ."""
649673 match = _make_match ()
650- self .assertEqual (IntentService ._missing_required_slots (match ), [])
674+ self .assertEqual (self . svc ._missing_required_slots (match ), [])
651675
652676 def test_all_required_present_passes (self ):
653- match = self ._match_with_required ({"query" : "some jazz" }, ["query" ])
654- self .assertEqual (IntentService ._missing_required_slots (match ), [])
677+ """All declared required_slots present in the slot map."""
678+ self ._register_template (required_slots = ["query" ])
679+ match = self ._make_match ({"query" : "some jazz" })
680+ self .assertEqual (self .svc ._missing_required_slots (match ), [])
655681
656682 def test_missing_required_slot_detected (self ):
657- match = self ._match_with_required ({}, ["query" ])
658- self .assertEqual (IntentService ._missing_required_slots (match ), ["query" ])
683+ """A required slot absent from the slot map is reported."""
684+ self ._register_template (required_slots = ["query" ])
685+ match = self ._make_match ({})
686+ self .assertEqual (self .svc ._missing_required_slots (match ), ["query" ])
659687
660688 def test_falsy_present_value_counts_as_present (self ):
661- """Spec/CodeRabbit fix: a present slot carrying a falsy value (0/False/
662- empty string) is a legitimate, PRESENT slot and MUST NOT be filtered out —
663- only a truly-abSENT key is missing."""
689+ """A falsy value (0/False/'') is still a PRESENT slot — only a missing
690+ key is absent (INTENT-3 §5.3)."""
691+ self . _register_template ( required_slots = [ "query" ])
664692 for present in ("" , 0 , False ):
665693 with self .subTest (present = present ):
666- match = self ._match_with_required ({"query" : present }, [ "query" ] )
667- self .assertEqual (IntentService ._missing_required_slots (match ), [],
694+ match = self ._make_match ({"query" : present })
695+ self .assertEqual (self . svc ._missing_required_slots (match ), [],
668696 f"falsy value { present !r} is present, not missing" )
669697
670698 def test_partial_required_slots_reported (self ):
671- match = self ._match_with_required ({"query" : "jazz" },
672- ["query" , "engine" ])
673- self .assertEqual (IntentService ._missing_required_slots (match ),
674- ["engine" ])
675-
676- def test_none_match_data_is_safe (self ):
677- match = IntentHandlerMatch (match_type = "x:y" , match_data = None ,
678- skill_id = "x" )
679- self .assertEqual (IntentService ._missing_required_slots (match ), [])
699+ """Only the slots missing from the slot map are reported."""
700+ self ._register_template (required_slots = ["query" , "engine" ])
701+ match = self ._make_match ({"query" : "jazz" })
702+ self .assertEqual (self .svc ._missing_required_slots (match ), ["engine" ])
703+
704+ def test_none_match_data_reports_all_as_missing (self ):
705+ """match_data=None means no slots were extracted — every required slot
706+ declared in the manifest is absent."""
707+ self ._register_template (skill_id = "x" , intent_name = "y" ,
708+ required_slots = ["q" ])
709+ match = IntentHandlerMatch (match_type = "x:y" , match_data = None , skill_id = "x" )
710+ self .assertEqual (self .svc ._missing_required_slots (match ), ["q" ])
711+
712+ def test_skill_with_no_manifest_entry_is_noop (self ):
713+ """A match from a skill whose intent is not in the manifest is not
714+ filtered — no registration data, no required_slots to enforce."""
715+ match = _make_match ()
716+ self .assertEqual (self .svc ._missing_required_slots (match ), [])
680717
681718 # --- end-to-end match-loop behaviour ------------------------------------
682719
683- def _run_loop (self , first_match ):
720+ def _run_loop (self , first_match , register_intent = True ):
684721 """Drive handle_utterance with a single pipeline stage returning
685722 ``first_match``, then a second stage that always declines, and return
686723 whether the first match was dispatched and whether the no-match path
687724 was reached."""
688725 svc = _make_service ()
726+ if register_intent :
727+ svc .intent_manifest ._on_register (Message (
728+ "ovos.intent.register.template" ,
729+ {"skill_id" : self .SKILL_ID ,
730+ "intent_name" : self .INTENT_NAME ,
731+ "lang" : "en-US" ,
732+ "samples" : ["play {query}" ],
733+ "required_slots" : ["query" ]},
734+ {"skill_id" : self .SKILL_ID },
735+ ))
689736 sess = Session ("s" )
690737 emitted = []
691738 svc .bus .emit = lambda m : emitted .append (m )
692739 svc .send_complete_intent_failure = MagicMock ()
693- # The §6.2 backstop runs strictly BEFORE _dispatch_match, so stub
694- # dispatch out: a match that survives the backstop reaches it, a filtered
695- # one never does.
696740 dispatched = []
697741
698742 def fake_dispatch (match , message , lang , pipeline_id = None ):
@@ -716,13 +760,13 @@ def test_match_missing_required_slot_is_skipped_to_no_match(self):
716760 """§6.2: a match missing a required slot is treated as if the plugin
717761 declined; iteration continues to no-match (send_complete_intent_failure
718762 emitted ovos.intent.unmatched)."""
719- match = self ._match_with_required ({}, [ "query" ] )
763+ match = self ._make_match ({})
720764 dispatched , _ , unmatched = self ._run_loop (match )
721765 self .assertFalse (dispatched , "match missing required_slots must not dispatch" )
722766 self .assertTrue (unmatched , "missing required_slots must reach the no-match path" )
723767
724768 def test_match_with_all_required_slots_dispatches (self ):
725- match = self ._match_with_required ({"query" : "jazz" }, [ "query" ] )
769+ match = self ._make_match ({"query" : "jazz" })
726770 dispatched , _ , unmatched = self ._run_loop (match )
727771 self .assertTrue (dispatched , "conformant match must dispatch" )
728772 self .assertFalse (unmatched , "conformant match must not fall through to no-match" )
0 commit comments