"""
LSO FSM Workflow Tests
Tests FSM state tracking through realistic user workflows using the SDK/API.
Validates that FSM correctly tracks state changes during actual user journeys.
This test file focuses on LSO-specific functionality:
- Project lifecycle: CREATED -> IN_PROGRESS -> COMPLETED
- Task lifecycle: CREATED -> COMPLETED -> IN_PROGRESS -> COMPLETED
- Annotation lifecycle: SUBMITTED (on create), SUBMITTED (on update)
LSE-specific transitions (reviews, project settings, annotation drafts) are tested in LSE.
"""
import pytest
from fsm.state_choices import AnnotationStateChoices, ProjectStateChoices, TaskStateChoices
from fsm.state_manager import StateManager
from label_studio_sdk.client import LabelStudio
from projects.models import Project
from tasks.models import Annotation, Task
pytestmark = pytest.mark.django_db
# Helper functions
def assert_project_state(project_id, expected_state):
"""Assert project has expected FSM state"""
project = Project.objects.get(pk=project_id)
actual = StateManager.get_current_state_value(project)
assert actual == expected_state, f'Expected project state {expected_state}, got {actual}'
def assert_task_state(task_id, expected_state):
"""Assert task has expected FSM state"""
task = Task.objects.get(pk=task_id)
actual = StateManager.get_current_state_value(task)
assert actual == expected_state, f'Expected task state {expected_state}, got {actual}'
def assert_annotation_state(annotation_id, expected_state):
"""Assert annotation has expected FSM state"""
annotation = Annotation.objects.get(pk=annotation_id)
actual = StateManager.get_current_state_value(annotation)
assert actual == expected_state, f'Expected annotation state {expected_state}, got {actual}'
class TestProjectWorkflows:
"""Test project FSM state tracking through realistic workflows"""
def test_project_creation_workflow(self, django_live_url, business_client):
"""
User creates project -> Project state = CREATED
Validates:
- Project is created with CREATED state
- FSM captures project creation
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Create project via SDK
project = ls.projects.create(
title='Test Project - Creation Workflow',
label_config='',
)
# Verify project state
assert_project_state(project.id, ProjectStateChoices.CREATED)
def test_project_in_progress_workflow(self, django_live_url, business_client):
"""
First annotation on any task -> Project CREATED -> IN_PROGRESS
Validates:
- Project starts in CREATED state
- First annotation submission triggers project IN_PROGRESS
- Task transitions to COMPLETED
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Create project and tasks
project = ls.projects.create(
title='Test Project - In Progress Workflow',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
ls.tasks.create(project=project.id, data={'text': 'Task 2'})
# Verify initial states
assert_project_state(project.id, ProjectStateChoices.CREATED)
tasks = list(ls.tasks.list(project=project.id))
assert len(tasks) == 2
assert_task_state(tasks[0].id, TaskStateChoices.CREATED)
# Submit annotation on first task
ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
# Verify task completed and project in progress
assert_task_state(tasks[0].id, TaskStateChoices.COMPLETED)
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
def test_project_completion_workflow(self, django_live_url, business_client):
"""
All tasks completed -> Project IN_PROGRESS -> COMPLETED
Validates:
- Project moves to COMPLETED when all tasks are completed
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Create project and tasks
project = ls.projects.create(
title='Test Project - Completion Workflow',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
ls.tasks.create(project=project.id, data={'text': 'Task 2'})
tasks = list(ls.tasks.list(project=project.id))
# Submit annotation on first task -> project IN_PROGRESS
ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
# Submit annotation on second task -> project COMPLETED
ls.annotations.create(
id=tasks[1].id,
result=[{'value': {'choices': ['negative']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
# Verify all tasks completed and project completed
assert_task_state(tasks[0].id, TaskStateChoices.COMPLETED)
assert_task_state(tasks[1].id, TaskStateChoices.COMPLETED)
assert_project_state(project.id, ProjectStateChoices.COMPLETED)
def test_project_back_to_in_progress_workflow(self, django_live_url, business_client):
"""
Task becomes incomplete -> Project COMPLETED -> IN_PROGRESS
Validates:
- Deleting annotations from a task moves task to IN_PROGRESS
- Project transitions back to IN_PROGRESS when any task is incomplete
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Create project and tasks
project = ls.projects.create(
title='Test Project - Back to In Progress',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
ls.tasks.create(project=project.id, data={'text': 'Task 2'})
tasks = list(ls.tasks.list(project=project.id))
# Complete both tasks
annotation1 = ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
ls.annotations.create(
id=tasks[1].id,
result=[{'value': {'choices': ['negative']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_project_state(project.id, ProjectStateChoices.COMPLETED)
# Delete annotation from first task
ls.annotations.delete(id=annotation1.id)
# Verify task moved to IN_PROGRESS and project back to IN_PROGRESS
assert_task_state(tasks[0].id, TaskStateChoices.IN_PROGRESS)
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
class TestTaskWorkflows:
"""Test task FSM state tracking through realistic workflows"""
def test_task_import_workflow(self, django_live_url, business_client):
"""
User imports tasks -> Each task state = CREATED
Validates:
- Tasks are created with CREATED state
- FSM captures task creation
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Task Import',
label_config='',
)
# Create tasks via SDK
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
ls.tasks.create(project=project.id, data={'text': 'Task 2'})
ls.tasks.create(project=project.id, data={'text': 'Task 3'})
# Verify all tasks are CREATED
tasks = list(ls.tasks.list(project=project.id))
assert len(tasks) == 3
for task in tasks:
assert_task_state(task.id, TaskStateChoices.CREATED)
def test_task_completion_workflow(self, django_live_url, business_client):
"""
First annotation submitted -> Task CREATED -> COMPLETED
Validates:
- Task transitions to COMPLETED when annotation is submitted
- Annotation is in SUBMITTED state
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Task Completion',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
tasks = list(ls.tasks.list(project=project.id))
task_id = tasks[0].id
# Verify initial state
assert_task_state(task_id, TaskStateChoices.CREATED)
# Submit annotation
annotation = ls.annotations.create(
id=task_id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
# Verify task completed
assert_task_state(task_id, TaskStateChoices.COMPLETED)
assert_annotation_state(annotation.id, AnnotationStateChoices.SUBMITTED)
def test_task_in_progress_workflow(self, django_live_url, business_client):
"""
All annotations deleted -> Task COMPLETED -> IN_PROGRESS
Validates:
- Task transitions to IN_PROGRESS when all annotations are deleted
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Task In Progress',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
tasks = list(ls.tasks.list(project=project.id))
task_id = tasks[0].id
# Submit and verify completion
annotation = ls.annotations.create(
id=task_id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_task_state(task_id, TaskStateChoices.COMPLETED)
# Delete annotation
ls.annotations.delete(id=annotation.id)
# Verify task in progress
assert_task_state(task_id, TaskStateChoices.IN_PROGRESS)
def test_task_re_completion_workflow(self, django_live_url, business_client):
"""
Annotation submitted on IN_PROGRESS task -> Task IN_PROGRESS -> COMPLETED
Validates:
- Task can transition back to COMPLETED after being IN_PROGRESS
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Task Re-completion',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
tasks = list(ls.tasks.list(project=project.id))
task_id = tasks[0].id
# Submit, delete, verify IN_PROGRESS
annotation1 = ls.annotations.create(
id=task_id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
ls.annotations.delete(id=annotation1.id)
assert_task_state(task_id, TaskStateChoices.IN_PROGRESS)
# Re-submit annotation
ls.annotations.create(
id=task_id,
result=[{'value': {'choices': ['negative']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
# Verify task completed again
assert_task_state(task_id, TaskStateChoices.COMPLETED)
class TestAnnotationWorkflows:
"""Test annotation FSM state tracking through realistic workflows"""
def test_annotation_submission_workflow(self, django_live_url, business_client):
"""
User submits annotation -> Annotation state = SUBMITTED
Validates:
- Annotation is created with SUBMITTED state
- FSM captures annotation creation
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Annotation Submission',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
tasks = list(ls.tasks.list(project=project.id))
# Submit annotation
annotation = ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
# Verify annotation state
assert_annotation_state(annotation.id, AnnotationStateChoices.SUBMITTED)
# Verify FSM state record count
annotation_obj = Annotation.objects.get(pk=annotation.id)
state_count = StateManager.get_state_history(annotation_obj).count()
assert state_count == 1, f'Expected 1 state record, got {state_count}'
def test_annotation_update_workflow(self, django_live_url, business_client):
"""
User updates annotation -> New state record (still SUBMITTED)
Validates:
- Annotation update creates new FSM state record
- State remains SUBMITTED
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Test Project - Annotation Update',
label_config='',
)
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
tasks = list(ls.tasks.list(project=project.id))
# Submit annotation
annotation = ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_annotation_state(annotation.id, AnnotationStateChoices.SUBMITTED)
# Update annotation
ls.annotations.update(
id=annotation.id,
result=[{'value': {'choices': ['negative']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
)
# Verify state still SUBMITTED but new state record created
assert_annotation_state(annotation.id, AnnotationStateChoices.SUBMITTED)
annotation_obj = Annotation.objects.get(pk=annotation.id)
state_count = StateManager.get_state_history(annotation_obj).count()
assert state_count == 2, f'Expected 2 state records, got {state_count}'
class TestEndToEndWorkflows:
"""Test complete end-to-end workflows"""
def test_complete_annotation_journey(self, django_live_url, business_client):
"""
Complete workflow:
1. Create project -> Project CREATED
2. Import 2 tasks -> Tasks CREATED
3. Submit annotation on task1 -> Task1 COMPLETED, Project IN_PROGRESS
4. Submit annotation on task2 -> Task2 COMPLETED, Project COMPLETED
5. Delete annotation from task1 -> Task1 IN_PROGRESS, Project IN_PROGRESS
6. Re-submit annotation on task1 -> Task1 COMPLETED, Project COMPLETED
Validates the complete FSM state flow for a typical annotation journey.
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Step 1: Create project
project = ls.projects.create(
title='Test Project - Complete Journey',
label_config='',
)
assert_project_state(project.id, ProjectStateChoices.CREATED)
# Step 2: Create 2 tasks
ls.tasks.create(project=project.id, data={'text': 'Task 1'})
ls.tasks.create(project=project.id, data={'text': 'Task 2'})
tasks = list(ls.tasks.list(project=project.id))
assert len(tasks) == 2
task1_id = tasks[0].id
task2_id = tasks[1].id
assert_task_state(task1_id, TaskStateChoices.CREATED)
assert_task_state(task2_id, TaskStateChoices.CREATED)
# Step 3: Submit annotation on task1
annotation1 = ls.annotations.create(
id=task1_id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_task_state(task1_id, TaskStateChoices.COMPLETED)
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
assert_annotation_state(annotation1.id, AnnotationStateChoices.SUBMITTED)
# Step 4: Submit annotation on task2
annotation2 = ls.annotations.create(
id=task2_id,
result=[{'value': {'choices': ['negative']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_task_state(task2_id, TaskStateChoices.COMPLETED)
assert_project_state(project.id, ProjectStateChoices.COMPLETED)
assert_annotation_state(annotation2.id, AnnotationStateChoices.SUBMITTED)
# Step 5: Delete annotation from task1
ls.annotations.delete(id=annotation1.id)
assert_task_state(task1_id, TaskStateChoices.IN_PROGRESS)
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
# Step 6: Re-submit annotation on task1
annotation3 = ls.annotations.create(
id=task1_id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=5.0,
)
assert_task_state(task1_id, TaskStateChoices.COMPLETED)
assert_project_state(project.id, ProjectStateChoices.COMPLETED)
assert_annotation_state(annotation3.id, AnnotationStateChoices.SUBMITTED)
class TestColdStartScenarios:
"""
Test FSM behavior when entities exist without state records.
These tests simulate "cold start" scenarios that occur when:
1. FSM is deployed to production with pre-existing data
2. Entities exist in the database but have no FSM state records
3. First FSM interaction must properly initialize states
"""
@pytest.fixture(autouse=True)
def setup_context(self, business_client):
"""Ensure CurrentContext has user set for FSM operations"""
from core.current_request import CurrentContext
# Set the user from business_client to CurrentContext
user = business_client.user
CurrentContext.set_user(user)
if hasattr(user, 'active_organization') and user.active_organization:
CurrentContext.set_organization_id(user.active_organization.id)
yield
# Cleanup
CurrentContext.clear()
def test_annotation_deletion_on_task_without_state(self, django_live_url, business_client, configured_project):
"""
Test: Annotation deletion on task that has no FSM state record.
Steps:
1. Create task directly (bypassing FSM auto-transitions)
2. Add annotation directly (bypassing FSM)
3. Delete annotation via SDK
4. Verify states are initialized and updated correctly
"""
from fsm.state_choices import TaskStateChoices
from fsm.state_models import TaskState
from tasks.models import Annotation, Task
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Step 1: Create task directly without FSM state
task = Task(data={'text': 'Test cold start'}, project=configured_project)
task.save(skip_fsm=True)
# Verify no state exists
assert TaskState.objects.filter(task=task).count() == 0
# Step 2: Create annotation directly
annotation = Annotation(
task=task,
project=configured_project,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
)
annotation.save(skip_fsm=True)
task.is_labeled = True
task.save(skip_fsm=True)
# Step 3: Delete annotation via SDK (this triggers FSM logic)
ls.annotations.delete(id=annotation.id)
# Step 4: Verify task state was initialized and updated
task.refresh_from_db()
assert not task.is_labeled # Annotation was deleted
# Task state should now exist and be IN_PROGRESS
task_states = TaskState.objects.filter(task=task).order_by('-id')
assert task_states.count() >= 1 # At least one state record created
latest_state = task_states.first()
assert latest_state.state in [TaskStateChoices.IN_PROGRESS, TaskStateChoices.CREATED]
def test_annotation_submission_on_task_without_state(self, django_live_url, business_client, configured_project):
"""
Test: Annotation submission on task that has no FSM state record.
Steps:
1. Create task directly (bypassing FSM)
2. Submit annotation via SDK
3. Verify task and project states are initialized correctly
"""
from fsm.state_choices import ProjectStateChoices, TaskStateChoices
from fsm.state_models import ProjectState, TaskState
from tasks.models import Task
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
# Step 1: Create task without FSM state
task = Task(data={'text': 'Cold start annotation test'}, project=configured_project)
task.save(skip_fsm=True)
# Verify no states exist
assert TaskState.objects.filter(task=task).count() == 0
# Delete any project states that might exist
ProjectState.objects.filter(project=configured_project).delete()
# Step 2: Submit annotation via SDK
ls.annotations.create(
id=task.id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=1.0,
)
# Step 3: Verify states initialized
task_states = TaskState.objects.filter(task=task).order_by('-id')
assert task_states.count() >= 1
latest_task_state = task_states.first()
assert latest_task_state.state == TaskStateChoices.COMPLETED
project_states = ProjectState.objects.filter(project=configured_project).order_by('-id')
assert project_states.count() >= 1
latest_project_state = project_states.first()
assert latest_project_state.state in [ProjectStateChoices.IN_PROGRESS, ProjectStateChoices.COMPLETED]
def test_project_state_update_with_mixed_task_states(self, django_live_url, business_client, configured_project):
"""
Test: Project state update when some tasks have states and some don't.
Steps:
1. Create multiple tasks without FSM states
2. Update project state via annotation submission
3. Verify all task states are initialized
4. Verify project state is correct
"""
from fsm.state_choices import ProjectStateChoices, TaskStateChoices
from fsm.state_manager import get_state_manager
from fsm.state_models import TaskState
from tasks.models import Task
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
StateManager = get_state_manager()
# Step 1: Create two tasks without FSM states
task1 = Task(data={'text': 'Task 1'}, project=configured_project)
task1.save(skip_fsm=True)
task2 = Task(data={'text': 'Task 2'}, project=configured_project)
task2.save(skip_fsm=True)
# Verify no tasks have states initially
assert not TaskState.objects.filter(task=task1).exists()
assert not TaskState.objects.filter(task=task2).exists()
# Step 2: Submit annotation on first task only via SDK
ls.annotations.create(
id=task1.id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=1.0,
)
# Step 3: Verify both tasks now have states
# task1 should have COMPLETED state (annotation submitted)
task1_state = StateManager.get_current_state_value(task1)
assert task1_state == TaskStateChoices.COMPLETED
# task2 should also have been initialized during project state calculation
task2_state = StateManager.get_current_state_value(task2)
assert task2_state in [
TaskStateChoices.CREATED,
TaskStateChoices.IN_PROGRESS,
None,
] # May or may not be initialized yet
# Step 4: Verify project state is correct (IN_PROGRESS - some tasks completed)
project_state = StateManager.get_current_state_value(configured_project)
assert project_state == ProjectStateChoices.IN_PROGRESS
def test_bulk_task_processing_cold_start(self, django_live_url, business_client):
"""
Test: Bulk processing of tasks when none have FSM states.
Steps:
1. Create a new project with multiple tasks without FSM states
2. Submit annotations on all tasks via SDK
3. Verify states are correctly initialized for all
4. Verify project transitions correctly through states
"""
from fsm.state_choices import ProjectStateChoices, TaskStateChoices
from fsm.state_manager import get_state_manager
from fsm.state_models import TaskState
from projects.models import Project
from tasks.models import Task
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
StateManager = get_state_manager()
# Create a new project with FSM
project = Project(
title='Bulk Cold Start Test',
label_config='',
created_by=business_client.user,
)
project.save()
# Step 1: Create 3 tasks without FSM states
tasks = []
for i in range(3):
task = Task(data={'text': f'Bulk task {i}'}, project=project)
task.save(skip_fsm=True)
tasks.append(task)
assert not TaskState.objects.filter(task=task).exists()
# Step 2: Submit annotations on all tasks via SDK
for task in tasks:
ls.annotations.create(
id=task.id,
result=[
{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}
],
lead_time=1.0,
)
# Step 3: Verify all tasks have correct states
for task in tasks:
task_state = StateManager.get_current_state_value(task)
assert task_state == TaskStateChoices.COMPLETED
# Step 4: Verify project is COMPLETED (all tasks completed)
project_state = StateManager.get_current_state_value(project)
assert project_state == ProjectStateChoices.COMPLETED
def test_project_completes_after_deleting_unfinished_tasks(django_live_url, business_client):
"""
Deleting all unfinished tasks should complete the project if the remaining task(s) are completed.
Steps:
- Create project with 4 tasks
- Annotate 1 task (project -> IN_PROGRESS)
- Delete the 3 unannotated tasks via Delete Tasks action
- Expect project -> COMPLETED (only completed task remains)
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)
project = ls.projects.create(
title='Complete after deleting unfinished',
label_config='',
)
# Create 4 tasks
tasks = [ls.tasks.create(project=project.id, data={'text': f'Task {i}'}) for i in range(4)]
assert len(list(ls.tasks.list(project=project.id))) == 4
# Annotate the first task
ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=1.0,
)
# Project should be IN_PROGRESS with mixed completion
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)
# Delete remaining 3 unannotated tasks using Data Manager action
ids_to_delete = [t.id for t in tasks[1:]]
ls.actions.create(project=project.id, id='delete_tasks', selected_items={'all': False, 'included': ids_to_delete})
# Only one task should remain
remaining = list(ls.tasks.list(project=project.id))
assert len(remaining) == 1
# Project should now be COMPLETED since all remaining tasks are completed
assert_project_state(project.id, ProjectStateChoices.COMPLETED)