1294.92 Memory 동작 검증 테스트

1. Memory 동작의 검증 대상

SequenceNode와 FallbackNode는 current_child_idx_ 멤버 변수를 통해 메모리 기반 재진입(Memory) 동작을 구현한다. 이 동작의 핵심은 RUNNING 상태를 반환한 자식의 인덱스를 기억하여, 다음 Tick에서 해당 자식부터 실행을 재개하고 이전에 완료된 자식을 건너뛰는 것이다. Memory 동작 검증 테스트는 이 인덱스 관리의 정확성, 인덱스 초기화의 적시성, 그리고 건너뛰기 동작의 올바름을 검증한다(Faconti, 2022).

2. SequenceNode의 Memory 검증

2.1 기본 재진입 테스트

TEST(SequenceMemory, SkipCompletedChildren) {
    // 3개 자식: action1, action2, action3
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    
    // Tick 1: action1 S, action2 R → RUNNING
    auto result1 = seq.executeTick();
    EXPECT_EQ(result1, BT::NodeStatus::RUNNING);
    EXPECT_EQ(action1->tickCount(), 1);
    EXPECT_EQ(action2->tickCount(), 1);
    EXPECT_EQ(action3->tickCount(), 0);
    
    // Tick 2: action2부터 재개 (action1 건너뜀)
    action2->setReturn(BT::NodeStatus::SUCCESS);
    action3->setReturn(BT::NodeStatus::SUCCESS);
    
    action1->resetTickCount();
    action2->resetTickCount();
    
    auto result2 = seq.executeTick();
    EXPECT_EQ(result2, BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 0);  // 건너뜀!
    EXPECT_EQ(action2->tickCount(), 1);
    EXPECT_EQ(action3->tickCount(), 1);
}

이 테스트의 핵심 검증 지점은 Tick 2에서 action1->tickCount()가 0인 것이다. SequenceNode의 current_child_idx_가 1을 유지하므로, action1은 재실행되지 않는다.

2.2 다단계 재진입 테스트

TEST(SequenceMemory, MultiStageReentry) {
    // Tick 1: action1 R
    action1->setReturn(BT::NodeStatus::RUNNING);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    
    // Tick 2: action1 S, action2 R (idx: 0→1)
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    
    // Tick 3: action2 S, action3 R (idx: 1→2)
    action2->setReturn(BT::NodeStatus::SUCCESS);
    action3->setReturn(BT::NodeStatus::RUNNING);
    
    action1->resetTickCount();
    
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    EXPECT_EQ(action1->tickCount(), 0);  // Tick 3에서 건너뜀
    
    // Tick 4: action3 S → 전체 SUCCESS (idx: 0으로 초기화)
    action3->setReturn(BT::NodeStatus::SUCCESS);
    action2->resetTickCount();
    
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 0);  // 여전히 건너뜀
    EXPECT_EQ(action2->tickCount(), 0);  // 건너뜀
}

current_child_idx_가 0→1→2→0으로 순차 진행하고, 최종 SUCCESS 시 0으로 초기화되는 전체 생애 주기를 검증한다.

2.3 FAILURE에 의한 인덱스 초기화 테스트

TEST(SequenceMemory, FailureResetsIndex) {
    // Tick 1: action1 S, action2 R (idx=1)
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    
    // Tick 2: action2 F → FAILURE (idx=0으로 초기화)
    action2->setReturn(BT::NodeStatus::FAILURE);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::FAILURE);
    
    // Tick 3: 다시 action1부터 시작
    action1->resetTickCount();
    action2->setReturn(BT::NodeStatus::SUCCESS);
    action3->setReturn(BT::NodeStatus::SUCCESS);
    
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 1);  // action1 다시 실행
}

FAILURE가 발생하면 current_child_idx_가 0으로 초기화되어, 다음 실행 사이클에서 첫 번째 자식부터 다시 시작하는 것을 검증한다.

3. FallbackNode의 Memory 검증

3.1 기본 재진입 테스트

TEST(FallbackMemory, SkipFailedChildren) {
    // Tick 1: action1 F, action2 R → RUNNING
    action1->setReturn(BT::NodeStatus::FAILURE);
    action2->setReturn(BT::NodeStatus::RUNNING);
    
    auto result1 = fb.executeTick();
    EXPECT_EQ(result1, BT::NodeStatus::RUNNING);
    
    // Tick 2: action2부터 재개 (action1 건너뜀)
    action2->setReturn(BT::NodeStatus::SUCCESS);
    action1->resetTickCount();
    
    auto result2 = fb.executeTick();
    EXPECT_EQ(result2, BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 0);  // FAILURE였으므로 건너뜀
}

FallbackNode에서는 FAILURE를 반환한 자식을 건너뛴다. Sequence가 SUCCESS를 건너뛰는 것과 대칭적이다.

3.2 SUCCESS에 의한 인덱스 초기화 테스트

TEST(FallbackMemory, SuccessResetsIndex) {
    // Tick 1: action1 F, action2 R (idx=1)
    action1->setReturn(BT::NodeStatus::FAILURE);
    action2->setReturn(BT::NodeStatus::RUNNING);
    EXPECT_EQ(fb.executeTick(), BT::NodeStatus::RUNNING);
    
    // Tick 2: action2 S → SUCCESS (idx=0으로 초기화)
    action2->setReturn(BT::NodeStatus::SUCCESS);
    EXPECT_EQ(fb.executeTick(), BT::NodeStatus::SUCCESS);
    
    // Tick 3: 다시 action1부터 시작
    action1->resetTickCount();
    action1->setReturn(BT::NodeStatus::SUCCESS);
    
    EXPECT_EQ(fb.executeTick(), BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 1);  // action1 다시 실행
}

3.3 전체 FAILURE에 의한 인덱스 초기화 테스트

TEST(FallbackMemory, AllFailureResetsIndex) {
    // Tick 1: action1 F, action2 F, action3 F → FAILURE
    action1->setReturn(BT::NodeStatus::FAILURE);
    action2->setReturn(BT::NodeStatus::FAILURE);
    action3->setReturn(BT::NodeStatus::FAILURE);
    EXPECT_EQ(fb.executeTick(), BT::NodeStatus::FAILURE);
    
    // Tick 2: 다시 action1부터 시작
    action1->resetTickCount();
    action1->setReturn(BT::NodeStatus::SUCCESS);
    
    EXPECT_EQ(fb.executeTick(), BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 1);
}

4. Halt에 의한 인덱스 초기화 검증

4.1 외부 Halt의 인덱스 영향

TEST(SequenceMemory, HaltResetsIndex) {
    // Tick 1: action1 S, action2 R (idx=1)
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    
    // 외부에서 Halt 호출
    seq.halt();
    
    // Halt 후 다시 Tick: action1부터 시작해야 함
    action1->resetTickCount();
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::SUCCESS);
    action3->setReturn(BT::NodeStatus::SUCCESS);
    
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::SUCCESS);
    EXPECT_EQ(action1->tickCount(), 1);  // action1 재실행
}

halt() 메서드는 current_child_idx_를 0으로 초기화한다. 따라서 Halt 이후의 Tick은 항상 첫 번째 자식부터 시작해야 한다.

4.2 Halt 시 RUNNING 자식의 정지 검증

TEST(SequenceMemory, HaltStopsRunningChild) {
    // action2가 비동기 노드
    action1->setReturn(BT::NodeStatus::SUCCESS);
    asyncAction2->setReturnOnStart(BT::NodeStatus::RUNNING);
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
    
    // Halt
    seq.halt();
    
    // asyncAction2의 onHalted()가 호출되었는지 검증
    EXPECT_EQ(asyncAction2->haltCount(), 1);
    
    // 노드 상태가 IDLE로 복원되었는지 검증
    EXPECT_EQ(seq.status(), BT::NodeStatus::IDLE);
}

5. Memory 동작과 Reactive 동작의 비교 검증

5.1 동일 시나리오에서의 차이 검증

// Memory Sequence: 완료된 자식 건너뜀
TEST(MemoryVsReactive, SequenceMemorySkips) {
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    seq.executeTick();  // idx=1
    
    action1->resetTickCount();
    action2->setReturn(BT::NodeStatus::RUNNING);
    seq.executeTick();
    
    EXPECT_EQ(action1->tickCount(), 0);  // Memory: 건너뜀
}

// Reactive Sequence: 완료된 자식도 재평가
TEST(MemoryVsReactive, ReactiveSequenceReevaluates) {
    action1->setReturn(BT::NodeStatus::SUCCESS);
    action2->setReturn(BT::NodeStatus::RUNNING);
    rseq.executeTick();
    
    action1->resetTickCount();
    action2->setReturn(BT::NodeStatus::RUNNING);
    rseq.executeTick();
    
    EXPECT_EQ(action1->tickCount(), 1);  // Reactive: 재평가
}

동일한 자식 상태 시나리오에서 SequenceNode는 action1을 건너뛰지만 ReactiveSequence는 action1을 재평가한다. 이 차이가 Memory 동작의 본질이다.

6. 장기 실행 시나리오 테스트

6.1 다수 Tick에 걸친 인덱스 추적

TEST(SequenceMemory, LongRunningScenario) {
    const int NUM_CHILDREN = 5;
    std::vector<MockAsyncAction*> children(NUM_CHILDREN);
    // 5개 자식을 가진 Sequence 구성
    
    for (int i = 0; i < NUM_CHILDREN; i++) {
        children[i]->setReturnOnStart(BT::NodeStatus::RUNNING);
        children[i]->setReturnOnRunning(BT::NodeStatus::RUNNING);
    }
    
    // 각 자식이 3 Tick 후 SUCCESS를 반환하는 시나리오
    int total_ticks = 0;
    for (int i = 0; i < NUM_CHILDREN; i++) {
        for (int t = 0; t < 3; t++) {
            EXPECT_EQ(seq.executeTick(), BT::NodeStatus::RUNNING);
            total_ticks++;
            
            // 현재 자식 이전의 자식들이 실행되지 않았는지 확인
            for (int j = 0; j < i; j++) {
                // 이전 자식의 tick_count가 증가하지 않음
            }
        }
        children[i]->setReturnOnRunning(BT::NodeStatus::SUCCESS);
    }
    
    // 마지막 자식이 SUCCESS → 전체 SUCCESS
    EXPECT_EQ(seq.executeTick(), BT::NodeStatus::SUCCESS);
}

7. 인덱스 일관성 검증 패턴

7.1 상태 전이 매트릭스

Memory 동작의 인덱스 변화를 상태 전이 매트릭스로 정리하면 다음과 같다:

이벤트SequenceNode idxFallbackNode idx
초기 상태(IDLE)00
자식 i가 RUNNING 반환i 유지i 유지
자식 i가 SUCCESS 반환i+1로 증가 (Seq 진행)0으로 초기화 (조기 종료)
자식 i가 FAILURE 반환0으로 초기화 (조기 종료)i+1로 증가 (Fb 진행)
모든 자식 완료0으로 초기화0으로 초기화
halt() 호출0으로 초기화0으로 초기화

각 테스트 케이스는 이 매트릭스의 한 행 이상을 검증해야 하며, 전체 테스트 스위트가 모든 행을 포괄해야 한다.

8. Memory 검증 시 주의 사항

  1. Tick 간 상태 독립성: 테스트에서 모의 노드의 반환 상태를 Tick 사이에 변경할 때, 변경이 제어 노드의 내부 상태에 미치는 영향을 정확히 예측해야 한다. 특히 resetChildren() 호출 이후 모든 자식이 IDLE로 전환되므로, 모의 노드의 onStart()가 다시 호출될 수 있다.

  2. 비동기 노드와의 상호작용: StatefulActionNode를 자식으로 사용할 때, onStart()onRunning()의 호출 시점이 Memory 동작에 의해 달라진다. IDLE에서 첫 Tick은 onStart()를, 이후 Tick은 onRunning()을 호출하므로, 테스트에서 이 구분을 반영해야 한다.

  3. resetChildren()과 haltChild()의 차이: SequenceNode의 FAILURE 시 resetChildren()이 호출되어 모든 자식이 초기화된다. 반면 ReactiveSequence에서는 haltChild()로 특정 자식만 정지된다. Memory 테스트에서는 이 두 메서드의 효과 차이를 명확히 구분하여 검증해야 한다(Faconti, 2022).


참고 문헌

  • Colledanchise, M., & Ogren, P. (2018). Behavior Trees in Robotics and AI: An Introduction. CRC Press.
  • Faconti, D. (2022). BehaviorTree.CPP documentation and API reference. https://www.behaviortree.dev/