Created
February 19, 2026 05:44
-
-
Save musketyr/c44e3918dc0d8b46135964bb8fe64f32 to your computer and use it in GitHub Desktop.
Spock to JUnit 5 Migration Diff
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Spock → JUnit 5 Migration: RoiNotificationServiceSpec</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; line-height: 1.6; } | |
| h1 { text-align: center; margin-bottom: 20px; color: #333; } | |
| .section { background: white; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden; } | |
| .section-header { background: #2d3748; color: white; padding: 12px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } | |
| .section-header:hover { background: #4a5568; } | |
| .section-header h2 { font-size: 16px; font-weight: 600; } | |
| .section-header .toggle { font-size: 12px; } | |
| .section-content { display: block; } | |
| .section-content.collapsed { display: none; } | |
| .diff-container { display: grid; grid-template-columns: 1fr 1fr; } | |
| .diff-panel { padding: 16px; overflow-x: auto; } | |
| .diff-panel.left { background: #fff5f5; border-right: 1px solid #e2e8f0; } | |
| .diff-panel.right { background: #f0fff4; } | |
| .diff-panel h3 { font-size: 12px; text-transform: uppercase; color: #718096; margin-bottom: 12px; letter-spacing: 0.5px; } | |
| pre { font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 13px; white-space: pre-wrap; word-wrap: break-word; } | |
| .left pre { color: #c53030; } | |
| .right pre { color: #276749; } | |
| @media (max-width: 768px) { .diff-container { grid-template-columns: 1fr; } .diff-panel.left { border-right: none; border-bottom: 1px solid #e2e8f0; } } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>🦀 Spock → JUnit 5 Migration: RoiNotificationServiceSpec</h1> | |
| <p style="text-align: center; color: #666; margin-bottom: 20px;">Story: <a href="https://app.shortcut.com/agorapulse/story/185896">sc185896</a> | PR: <a href="https://github.com/agorapulse/platform/pull/72989">#72989</a></p> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>📦 Imports</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre>package agorapulse.roi.notification | |
| import agorapulse.commons.messagesource.interpolation.InterpolatingMessageSource | |
| import agorapulse.notification.sender.NotificationSender | |
| import agorapulse.organization.client.internal.OrganizationClient | |
| import agorapulse.organization.client.model.ManagerSummary | |
| import agorapulse.organization.client.model.OrganizationManagerSummary | |
| import agorapulse.organization.client.model.OrganizationSummary | |
| import agorapulse.roi.core.models.export.RoiEmailParameters | |
| import agorapulse.roi.core.models.parameters.RoiExportParametersBuilder | |
| import com.agorapulse.security.jwt.JsonWebTokenService | |
| import spock.lang.Specification | |
| import java.time.LocalDate</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre>package agorapulse.roi.notification; | |
| import agorapulse.commons.messagesource.interpolation.InterpolatingMessageSource; | |
| import agorapulse.notification.sender.NotificationSender; | |
| import agorapulse.organization.client.internal.OrganizationClient; | |
| import agorapulse.organization.client.model.ManagerSummary; | |
| import agorapulse.organization.client.model.OrganizationManagerSummary; | |
| import agorapulse.organization.client.model.OrganizationSummary; | |
| import agorapulse.roi.core.models.export.RoiEmailParameters; | |
| import agorapulse.roi.core.models.parameters.RoiExportParametersBuilder; | |
| import com.agorapulse.security.jwt.JsonWebTokenService; | |
| import org.junit.jupiter.api.BeforeEach; | |
| import org.junit.jupiter.api.Test; | |
| import org.junit.jupiter.api.extension.ExtendWith; | |
| import org.mockito.Mock; | |
| import org.mockito.junit.jupiter.MockitoExtension; | |
| import java.io.File; | |
| import java.time.LocalDate; | |
| import java.util.List; | |
| import java.util.Locale; | |
| import java.util.Map; | |
| import static org.assertj.core.api.Assertions.assertThat; | |
| import static org.mockito.ArgumentMatchers.any; | |
| import static org.mockito.Mockito.never; | |
| import static org.mockito.Mockito.verify; | |
| import static org.mockito.Mockito.when;</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🏗️ Class & Fields</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre>class RoiNotificationServiceSpec extends Specification { | |
| RoiNotificationService roiNotificationService | |
| InterpolatingMessageSource interpolatingMessageSource = Mock(InterpolatingMessageSource) | |
| JsonWebTokenService tokenService = Mock(JsonWebTokenService) | |
| NotificationSender notificationSender | |
| OrganizationClient organizationClient = Mock(OrganizationClient) | |
| void setup() { | |
| tokenService = Mock(JsonWebTokenService) | |
| notificationSender = Mock(NotificationSender) | |
| organizationClient = Mock(OrganizationClient) | |
| roiNotificationService = new RoiNotificationService('bucket', | |
| interpolatingMessageSource, | |
| tokenService, | |
| notificationSender, | |
| organizationClient, | |
| 'url' | |
| ) | |
| } | |
| </pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre>@ExtendWith(MockitoExtension.class) | |
| class RoiNotificationServiceTest { | |
| @Mock | |
| private InterpolatingMessageSource interpolatingMessageSource; | |
| @Mock | |
| private JsonWebTokenService tokenService; | |
| @Mock | |
| private NotificationSender notificationSender; | |
| @Mock | |
| private OrganizationClient organizationClient; | |
| private RoiNotificationService roiNotificationService; | |
| @BeforeEach | |
| void setUp() { | |
| roiNotificationService = new RoiNotificationService( | |
| "bucket", | |
| interpolatingMessageSource, | |
| tokenService, | |
| notificationSender, | |
| organizationClient, | |
| "url" | |
| ); | |
| } | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should not send notification if no recipient</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should not send notification if no recipient'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification([:], null, roiEmailParameters) | |
| then: | |
| !value | |
| 0 * notificationSender.send(_) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldNotSendNotificationIfNoRecipient() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(Map.of(), null, roiEmailParameters); | |
| // then | |
| assertThat(value).isFalse(); | |
| verify(notificationSender, never()).send(any(), any(), any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should not send notification if recipient id is not in org manager ids</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should not send notification if recipient id is not in org manager ids'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| ManagerSummary recipient = new ManagerSummary(identityId: '38', administratorEnabled: false) | |
| organizationClient.getOrganization(1) >> new OrganizationSummary(organizationManagers: []) | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification([:], recipient, roiEmailParameters) | |
| then: | |
| !value | |
| 0 * notificationSender.send(_, _, _) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldNotSendNotificationIfRecipientIdIsNotInOrgManagerIds() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| ManagerSummary recipient = new ManagerSummary(); | |
| recipient.setIdentityId("38"); | |
| recipient.setAdministratorEnabled(false); | |
| OrganizationSummary orgSummary = new OrganizationSummary(); | |
| orgSummary.setOrganizationManagers(List.of()); | |
| when(organizationClient.getOrganization(1)).thenReturn(orgSummary); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(Map.of(), recipient, roiEmailParameters); | |
| // then | |
| assertThat(value).isFalse(); | |
| verify(notificationSender, never()).send(any(), any(), any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should send notification if recipient id is not in org manager ids and is admin</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should send notification if recipient id is not in org manager ids and is admin'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| ManagerSummary recipient = new ManagerSummary(id: 38, administratorEnabled: true, timeZone: 'Europe/Paris', locale: Locale.FRANCE) | |
| organizationClient.getOrganization(1) >> new OrganizationSummary(organizationManagers: []) | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification([:], recipient, roiEmailParameters) | |
| then: | |
| value | |
| 1 * notificationSender.send(_, _, _) | |
| 1 * tokenService.sign(_) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldSendNotificationIfRecipientIdIsNotInOrgManagerIdsAndIsAdmin() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| ManagerSummary recipient = new ManagerSummary(); | |
| recipient.setId(38L); | |
| recipient.setAdministratorEnabled(true); | |
| recipient.setTimeZone("Europe/Paris"); | |
| recipient.setLocale(Locale.FRANCE); | |
| OrganizationSummary orgSummary = new OrganizationSummary(); | |
| orgSummary.setOrganizationManagers(List.of()); | |
| when(organizationClient.getOrganization(1)).thenReturn(orgSummary); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(Map.of(), recipient, roiEmailParameters); | |
| // then | |
| assertThat(value).isTrue(); | |
| verify(notificationSender).send(any(), any(), any()); | |
| verify(tokenService).sign(any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should send notification if recipient id is in org manager ids and is not admin</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should send notification if recipient id is in org manager ids and is not admin'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| ManagerSummary recipient = new ManagerSummary(id: 38, administratorEnabled: true, timeZone: 'Europe/Paris', locale: Locale.FRANCE) | |
| organizationClient.getOrganization(1) >> new OrganizationSummary(organizationManagers: [new OrganizationManagerSummary(managerId: 38)]) | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification([:], recipient, roiEmailParameters) | |
| then: | |
| value | |
| 1 * notificationSender.send(_, _, _) | |
| 1 * tokenService.sign(_) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldSendNotificationIfRecipientIdIsInOrgManagerIdsAndIsNotAdmin() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| ManagerSummary recipient = new ManagerSummary(); | |
| recipient.setId(38L); | |
| recipient.setAdministratorEnabled(true); | |
| recipient.setTimeZone("Europe/Paris"); | |
| recipient.setLocale(Locale.FRANCE); | |
| OrganizationManagerSummary orgManager = new OrganizationManagerSummary(); | |
| orgManager.setManagerId(38L); | |
| OrganizationSummary orgSummary = new OrganizationSummary(); | |
| orgSummary.setOrganizationManagers(List.of(orgManager)); | |
| when(organizationClient.getOrganization(1)).thenReturn(orgSummary); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(Map.of(), recipient, roiEmailParameters); | |
| // then | |
| assertThat(value).isTrue(); | |
| verify(notificationSender).send(any(), any(), any()); | |
| verify(tokenService).sign(any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should send notification with file messageSource</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should send notification with file messageSource'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| ManagerSummary recipient = new ManagerSummary(id: 38, administratorEnabled: true, timeZone: 'Europe/Paris', locale: Locale.FRANCE) | |
| organizationClient.getOrganization(1) >> new OrganizationSummary(organizationManagers: [new OrganizationManagerSummary(managerId: 38)]) | |
| Map fileData = [bucket: 'bucket', key: 'key', file: new File('test')] | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification(fileData, recipient, roiEmailParameters) | |
| then: | |
| value | |
| 1 * notificationSender.send(_, _, _) | |
| 1 * interpolatingMessageSource.get('email.roi-export-completed.attachment.label', Locale.FRANCE) | |
| 1 * tokenService.sign(_) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldSendNotificationWithFileMessageSource() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| ManagerSummary recipient = new ManagerSummary(); | |
| recipient.setId(38L); | |
| recipient.setAdministratorEnabled(true); | |
| recipient.setTimeZone("Europe/Paris"); | |
| recipient.setLocale(Locale.FRANCE); | |
| OrganizationManagerSummary orgManager = new OrganizationManagerSummary(); | |
| orgManager.setManagerId(38L); | |
| OrganizationSummary orgSummary = new OrganizationSummary(); | |
| orgSummary.setOrganizationManagers(List.of(orgManager)); | |
| when(organizationClient.getOrganization(1)).thenReturn(orgSummary); | |
| Map<String, Object> fileData = Map.of("bucket", "bucket", "key", "key", "file", new File("test")); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(fileData, recipient, roiEmailParameters); | |
| // then | |
| assertThat(value).isTrue(); | |
| verify(notificationSender).send(any(), any(), any()); | |
| verify(interpolatingMessageSource).get("email.roi-export-completed.attachment.label", Locale.FRANCE); | |
| verify(tokenService).sign(any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section-header" onclick="toggleSection(this)"> | |
| <h2>🧪 should send notification with no file messageSource</h2> | |
| <span class="toggle">▼</span> | |
| </div> | |
| <div class="section-content"> | |
| <div class="diff-container"> | |
| <div class="diff-panel left"> | |
| <h3>Spock (Groovy)</h3> | |
| <pre> void 'should send notification with no file messageSource'() { | |
| given: | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters() | |
| roiEmailParameters.mimeType = 'mime' | |
| roiEmailParameters.roiExportParameters = new RoiExportParametersBuilder('1', new ManagerSummary(identityId: '38'), LocalDate.now(), LocalDate.now(), 1, '2', 3).build() | |
| ManagerSummary recipient = new ManagerSummary(id: 38, administratorEnabled: true, timeZone: 'Europe/Paris', locale: Locale.FRANCE) | |
| organizationClient.getOrganization(1) >> new OrganizationSummary(organizationManagers: [new OrganizationManagerSummary(managerId: 38)]) | |
| when: | |
| boolean value = roiNotificationService.sendExportCompletedNotification(null, recipient, roiEmailParameters) | |
| then: | |
| value | |
| 1 * notificationSender.send(_, _, _) | |
| 1 * interpolatingMessageSource.get('email.roi-export-completed.download.label', Locale.FRANCE) | |
| 1 * tokenService.sign(_) | |
| }</pre> | |
| </div> | |
| <div class="diff-panel right"> | |
| <h3>JUnit 5 (Java)</h3> | |
| <pre> @Test | |
| void shouldSendNotificationWithNoFileMessageSource() { | |
| // given | |
| RoiEmailParameters roiEmailParameters = new RoiEmailParameters(); | |
| roiEmailParameters.setMimeType("mime"); | |
| ManagerSummary manager = new ManagerSummary(); | |
| manager.setIdentityId("38"); | |
| roiEmailParameters.setRoiExportParameters( | |
| new RoiExportParametersBuilder("1", manager, LocalDate.now(), LocalDate.now(), 1, "2", 3).build() | |
| ); | |
| ManagerSummary recipient = new ManagerSummary(); | |
| recipient.setId(38L); | |
| recipient.setAdministratorEnabled(true); | |
| recipient.setTimeZone("Europe/Paris"); | |
| recipient.setLocale(Locale.FRANCE); | |
| OrganizationManagerSummary orgManager = new OrganizationManagerSummary(); | |
| orgManager.setManagerId(38L); | |
| OrganizationSummary orgSummary = new OrganizationSummary(); | |
| orgSummary.setOrganizationManagers(List.of(orgManager)); | |
| when(organizationClient.getOrganization(1)).thenReturn(orgSummary); | |
| // when | |
| boolean value = roiNotificationService.sendExportCompletedNotification(null, recipient, roiEmailParameters); | |
| // then | |
| assertThat(value).isTrue(); | |
| verify(notificationSender).send(any(), any(), any()); | |
| verify(interpolatingMessageSource).get("email.roi-export-completed.download.label", Locale.FRANCE); | |
| verify(tokenService).sign(any()); | |
| }</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function toggleSection(header) { | |
| const content = header.nextElementSibling; | |
| const toggle = header.querySelector('.toggle'); | |
| content.classList.toggle('collapsed'); | |
| toggle.textContent = content.classList.contains('collapsed') ? '▶' : '▼'; | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment