File size: 21,424 Bytes
666f6cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
from solverforge_legacy.solver.test import ConstraintVerifier

from meeting_scheduling.domain import (
    Meeting,
    MeetingAssignment,
    MeetingSchedule,
    Person,
    PreferredAttendance,
    RequiredAttendance,
    Room,
    TimeGrain,
)
from meeting_scheduling.constraints import (
    define_constraints,
    room_conflict,
    avoid_overtime,
    required_attendance_conflict,
    required_room_capacity,
    start_and_end_on_same_day,
    required_and_preferred_attendance_conflict,
    preferred_attendance_conflict,
    room_stability,
)


DEFAULT_TIME_GRAINS = [
    TimeGrain(
        id=str(i + 1), grain_index=i, day_of_year=1, starting_minute_of_day=480 + i * 15
    )
    for i in range(8)
]

DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)
SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)
LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)
ROOM_A = Room(id="4", name="Room A", capacity=10)
ROOM_B = Room(id="5", name="Room B", capacity=10)


constraint_verifier = ConstraintVerifier.build(
    define_constraints, MeetingSchedule, MeetingAssignment
)


def test_room_conflict_unpenalized():
    """Test that no penalty is applied when meetings in the same room do not overlap."""
    meeting1 = create_meeting(1)
    left_assignment = create_meeting_assignment(
        0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )

    meeting2 = create_meeting(2)
    right_assignment = create_meeting_assignment(
        1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(room_conflict).given(
        left_assignment, right_assignment
    ).penalizes(0)


def test_room_conflict_penalized():
    """Test that a penalty is applied when meetings in the same room overlap."""
    meeting1 = create_meeting(1)
    left_assignment = create_meeting_assignment(
        0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )

    meeting2 = create_meeting(2)
    right_assignment = create_meeting_assignment(
        1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(room_conflict).given(
        left_assignment, right_assignment
    ).penalizes_by(2)


def test_avoid_overtime_unpenalized():
    """Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""
    meeting = create_meeting(1)
    meeting_assignment = create_meeting_assignment(
        0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(avoid_overtime).given(
        meeting_assignment, *DEFAULT_TIME_GRAINS
    ).penalizes(0)


def test_avoid_overtime_penalized():
    """Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""
    meeting = create_meeting(1)
    meeting_assignment = create_meeting_assignment(
        0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(avoid_overtime).given(
        meeting_assignment
    ).penalizes_by(3)


def test_required_attendance_conflict_unpenalized():
    """Test that no penalty is applied when a person does not have overlapping required meetings."""
    person = create_person(1)

    left_meeting = create_meeting(1, duration=2)
    required_attendance1 = create_required_attendance(0, person, left_meeting)

    right_meeting = create_meeting(2, duration=2)
    required_attendance2 = create_required_attendance(1, person, right_meeting)

    left_assignment = create_meeting_assignment(
        0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )
    right_assignment = create_meeting_assignment(
        1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(required_attendance_conflict).given(
        required_attendance1, required_attendance2, left_assignment, right_assignment
    ).penalizes(0)


def test_required_attendance_conflict_penalized():
    """Test that a penalty is applied when a person has overlapping required meetings."""
    person = create_person(1)

    left_meeting = create_meeting(1, duration=2)
    required_attendance1 = create_required_attendance(0, person, left_meeting)

    right_meeting = create_meeting(2, duration=2)
    required_attendance2 = create_required_attendance(1, person, right_meeting)

    left_assignment = create_meeting_assignment(
        0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )
    right_assignment = create_meeting_assignment(
        1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(required_attendance_conflict).given(
        required_attendance1, required_attendance2, left_assignment, right_assignment
    ).penalizes_by(1)


def test_required_room_capacity_unpenalized():
    """Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""
    person1 = create_person(1)
    person2 = create_person(2)

    meeting = create_meeting(1, duration=2)
    create_required_attendance(0, person1, meeting)
    create_preferred_attendance(1, person2, meeting)

    meeting_assignment = create_meeting_assignment(
        0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM
    )

    constraint_verifier.verify_that(required_room_capacity).given(
        meeting_assignment
    ).penalizes(0)


def test_required_room_capacity_penalized():
    """Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""
    person1 = create_person(1)
    person2 = create_person(2)

    meeting = create_meeting(1, duration=2)
    create_required_attendance(0, person1, meeting)
    create_preferred_attendance(1, person2, meeting)

    meeting_assignment = create_meeting_assignment(
        0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM
    )

    constraint_verifier.verify_that(required_room_capacity).given(
        meeting_assignment
    ).penalizes_by(1)


def test_start_and_end_on_same_day_unpenalized():
    """Test that no penalty is applied when a meeting starts and ends on the same day."""
    # Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)
    start_time_grain = TimeGrain(
        id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
    )
    end_time_grain = TimeGrain(
        id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525
    )  # Same day

    meeting = create_meeting(1)
    meeting_assignment = create_meeting_assignment(
        0, meeting, start_time_grain, DEFAULT_ROOM
    )

    constraint_verifier.verify_that(start_and_end_on_same_day).given(
        meeting_assignment, end_time_grain
    ).penalizes(0)


def test_start_and_end_on_same_day_penalized():
    """Test that a penalty is applied when a meeting starts and ends on different days."""
    # Need custom time grains to test different days (start=day 0, end=day 1)
    start_time_grain = TimeGrain(
        id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
    )
    end_time_grain = TimeGrain(
        id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525
    )  # Different day

    meeting = create_meeting(1)
    meeting_assignment = create_meeting_assignment(
        0, meeting, start_time_grain, DEFAULT_ROOM
    )

    constraint_verifier.verify_that(start_and_end_on_same_day).given(
        meeting_assignment, end_time_grain
    ).penalizes_by(1)


def test_multiple_constraint_violations():
    """Test that multiple constraints can be violated simultaneously."""
    person = create_person(1)

    left_meeting = create_meeting(1)
    required_attendance1 = create_required_attendance(0, person, left_meeting)
    left_assignment = create_meeting_assignment(
        0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
    )

    right_meeting = create_meeting(2)
    required_attendance2 = create_required_attendance(1, person, right_meeting)
    right_assignment = create_meeting_assignment(
        1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
    )

    constraint_verifier.verify_that(room_conflict).given(
        left_assignment, right_assignment
    ).penalizes_by(2)
    constraint_verifier.verify_that(required_attendance_conflict).given(
        required_attendance1, required_attendance2, left_assignment, right_assignment
    ).penalizes_by(2)


### Helper functions ###


def create_meeting(id, topic="Meeting", duration=4):
    """Helper to create a meeting with standard parameters."""
    return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)


def create_meeting_assignment(id, meeting, time_grain, room):
    """Helper to create a meeting assignment."""
    return MeetingAssignment(
        id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room
    )


def create_person(id):
    """Helper to create a person."""
    return Person(id=str(id), full_name=f"Person {id}")


def create_required_attendance(id, person, meeting):
    """Helper to create and link required attendance."""
    attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)
    meeting.required_attendances = [attendance]
    return attendance


def create_preferred_attendance(id, person, meeting):
    """Helper to create and link preferred attendance."""
    attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)
    meeting.preferred_attendances = [attendance]
    return attendance


# ========================================
# Required and Preferred Attendance Conflict Tests
# ========================================


def test_required_and_preferred_attendance_conflict_unpenalized():
    """Test no penalty when required and preferred meetings don't overlap."""
    person = create_person(1)

    # Meeting 1: grain 0-3 (duration=4), person required
    meeting1 = create_meeting(1, duration=4)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)

    # Meeting 2: grain 4-7 (duration=4), person preferred
    meeting2 = create_meeting(2, duration=4)
    attendance2 = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)

    constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(0)


def test_required_and_preferred_attendance_conflict_penalized():
    """Test penalty when person required at one meeting and preferred at overlapping meeting."""
    person = create_person(1)

    # Meeting 1: grain 0-3 (duration=4), person required
    meeting1 = create_meeting(1, duration=4)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)

    # Meeting 2: grain 2-5 (duration=4), person preferred, overlaps grains 2-3 (2 grains)
    meeting2 = create_meeting(2, duration=4)
    attendance2 = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], ROOM_A)

    # Overlap: grains 2-3 = 2 grains
    constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(2)


# ========================================
# Preferred Attendance Conflict Tests
# ========================================


def test_preferred_attendance_conflict_unpenalized():
    """Test no penalty when preferred attendee has non-overlapping meetings."""
    person = create_person(1)

    # Meeting 1: grain 0-3 (duration=4), person preferred
    meeting1 = create_meeting(1, duration=4)
    attendance1 = create_preferred_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)

    # Meeting 2: grain 4-7 (duration=4), person preferred
    meeting2 = create_meeting(2, duration=4)
    attendance2 = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)

    constraint_verifier.verify_that(preferred_attendance_conflict).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(0)


def test_preferred_attendance_conflict_penalized():
    """Test penalty when person preferred at multiple overlapping meetings."""
    person = create_person(1)

    # Meeting 1: grain 0-3 (duration=4), person preferred
    meeting1 = create_meeting(1, duration=4)
    attendance1 = create_preferred_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)

    # Meeting 2: grain 1-4 (duration=4), person preferred, overlaps grains 1-3 (3 grains)
    meeting2 = create_meeting(2, duration=4)
    attendance2 = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[1], ROOM_A)

    # Overlap: grains 1-3 = 3 grains
    constraint_verifier.verify_that(preferred_attendance_conflict).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(3)


# ========================================
# Room Stability Tests
# ========================================


def test_room_stability_same_room_no_penalty():
    """
    Test that no penalty is applied when a person attends consecutive
    meetings in the same room (stability is maintained).
    """
    person = create_person(1)

    # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
    meeting1 = create_meeting(1, duration=2)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)

    # Meeting 2: time grain 3-4 (duration=2) in ROOM_A (same room, gap of 1)
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_required_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_A)

    # Same room should not penalize
    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes(0)


def test_room_stability_different_room_with_required_attendance():
    """
    Test that a penalty is applied when a person with required attendance
    has to change rooms between closely scheduled meetings.
    Weighted penalty: back-to-back switches cost more than switches with gaps.
    """
    person = create_person(1)

    # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
    left_grain_index = 0
    left_duration = 2
    meeting1 = create_meeting(1, duration=left_duration)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)

    # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
    right_grain_index = 3
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_required_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)

    # Weighted penalty: 3 - gap, where gap = right_grain - left_duration - left_grain
    gap = right_grain_index - left_duration - left_grain_index
    expected_penalty = 3 - gap

    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(expected_penalty)


def test_room_stability_different_room_with_preferred_attendance():
    """
    Test that a penalty is applied when a person with preferred attendance
    has to change rooms between closely scheduled meetings.
    Weighted penalty applies to preferred attendance too.
    """
    person = create_person(1)

    # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
    left_grain_index = 0
    left_duration = 2
    meeting1 = create_meeting(1, duration=left_duration)
    attendance1 = create_preferred_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)

    # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
    right_grain_index = 3
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)

    # Weighted penalty: 3 - gap
    gap = right_grain_index - left_duration - left_grain_index
    expected_penalty = 3 - gap

    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(expected_penalty)


def test_room_stability_mixed_attendance_types():
    """
    Test that room stability penalty applies when mixing required and preferred
    attendance types for the same person.
    """
    person = create_person(1)

    # Meeting 1 with required attendance
    left_grain_index = 0
    left_duration = 2
    meeting1 = create_meeting(1, duration=left_duration)
    required_attendance = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)

    # Meeting 2 with preferred attendance
    right_grain_index = 3
    meeting2 = create_meeting(2, duration=2)
    preferred_attendance = create_preferred_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)

    # Weighted penalty: 3 - gap
    gap = right_grain_index - left_duration - left_grain_index
    expected_penalty = 3 - gap

    constraint_verifier.verify_that(room_stability).given(
        required_attendance, preferred_attendance, assignment1, assignment2
    ).penalizes_by(expected_penalty)


def test_room_stability_far_apart_meetings_no_penalty():
    """
    Test that no penalty is applied when meetings are far apart in time,
    even if they're in different rooms.
    """
    person = create_person(1)

    # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
    meeting1 = create_meeting(1, duration=2)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)

    # Meeting 2: time grain 6-7 (duration=2) in ROOM_B
    # gap = grain_index(6) - duration_in_grains(2) - grain_index(0) = 6 - 2 - 0 = 4 > 2
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_required_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[6], ROOM_B)

    # Far apart meetings should not penalize even with room change
    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes(0)


def test_room_stability_different_people_no_penalty():
    """
    Test that no penalty is applied when different people have meetings
    in different rooms (room stability is per-person).
    """
    person1 = create_person(1)
    person2 = create_person(2)

    # Person 1's meeting in ROOM_A
    meeting1 = create_meeting(1, duration=2)
    attendance1 = create_required_attendance(0, person1, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)

    # Person 2's meeting in ROOM_B (different person, should not affect stability)
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_required_attendance(1, person2, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_B)

    # Different people should not trigger room stability penalty
    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes(0)


def test_room_stability_back_to_back_highest_penalty():
    """
    Test that back-to-back room switches (gap=0) incur the highest penalty (3).
    This verifies the weighted penalty gradient: closer switches cost more.
    """
    person = create_person(1)

    # Meeting 1: grain 0-1 (duration=2) in ROOM_A
    left_grain_index = 0
    left_duration = 2
    meeting1 = create_meeting(1, duration=left_duration)
    attendance1 = create_required_attendance(0, person, meeting1)
    assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)

    # Meeting 2: grain 2-3 (immediately after) in ROOM_B
    right_grain_index = 2  # Starts right after meeting1 ends
    meeting2 = create_meeting(2, duration=2)
    attendance2 = create_required_attendance(1, person, meeting2)
    assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)

    # gap = 2 - 2 - 0 = 0, penalty = 3 - 0 = 3
    gap = right_grain_index - left_duration - left_grain_index
    expected_penalty = 3 - gap
    assert gap == 0, f"Test setup error: expected gap=0, got {gap}"
    assert expected_penalty == 3, f"Test setup error: expected penalty=3, got {expected_penalty}"

    constraint_verifier.verify_that(room_stability).given(
        attendance1, attendance2, assignment1, assignment2
    ).penalizes_by(expected_penalty)