View Javadoc
1   /*
2    * SPDX-License-Identifier: LGPL-2.1-or-later
3    * See LICENSE file for details.
4    *
5    * Copyright 2012-2026 Hazendaz.
6    */
7   package net.alchim31.maven.yuicompressor;
8   
9   import java.io.File;
10  import java.io.IOException;
11  import java.lang.reflect.Field;
12  import java.nio.charset.StandardCharsets;
13  import java.nio.file.Files;
14  import java.nio.file.StandardOpenOption;
15  import java.util.Arrays;
16  import java.util.Collection;
17  import java.util.HashSet;
18  import java.util.Set;
19  
20  import org.codehaus.plexus.build.DefaultBuildContext;
21  import org.junit.jupiter.api.Assertions;
22  import org.junit.jupiter.api.BeforeEach;
23  import org.junit.jupiter.api.Test;
24  import org.junit.jupiter.api.extension.ExtendWith;
25  import org.junit.jupiter.api.io.TempDir;
26  import org.mockito.ArgumentMatchers;
27  import org.mockito.Mock;
28  import org.mockito.Mockito;
29  import org.mockito.junit.jupiter.MockitoExtension;
30  import org.sonatype.plexus.build.incremental.BuildContext;
31  
32  /**
33   * The Class AggregationTestCase.
34   */
35  // Note: public here needed for javadocs to work so don't remove it
36  @ExtendWith(MockitoExtension.class)
37  public class AggregationTestCase {
38  
39      /** The dir. */
40      @TempDir
41      File dir;
42  
43      /** The legacy build context. */
44      @Mock
45      BuildContext legacyBuildContext;
46  
47      /** The default build context. */
48      DefaultBuildContext defaultBuildContext;
49  
50      /**
51       * Sets the up.
52       *
53       * @throws IOException
54       *             the io exception
55       */
56      @BeforeEach
57      void setUp() throws IOException {
58          // Ensure the mock returns a real OutputStream for output files
59          Mockito.lenient().when(this.legacyBuildContext.newFileOutputStream(ArgumentMatchers.any(File.class)))
60                  .thenAnswer(invocation -> Files.newOutputStream(((File) invocation.getArgument(0)).toPath()));
61          this.defaultBuildContext = new DefaultBuildContext(this.legacyBuildContext);
62      }
63  
64      /**
65       * Test 0 to 1.
66       *
67       * @throws IOException
68       *             the IO exception
69       */
70      @Test
71      void test0to1() throws IOException {
72          final var target = new Aggregation();
73          target.setOutput(this.dir.toPath().resolve("output.js").toFile());
74  
75          Assertions.assertFalse(target.getOutput().exists());
76          target.run(null, this.defaultBuildContext);
77          Assertions.assertFalse(target.getOutput().exists());
78  
79          target.setIncludes(new String[] {});
80          Assertions.assertFalse(target.getOutput().exists());
81          target.run(null, this.defaultBuildContext);
82          Assertions.assertFalse(target.getOutput().exists());
83  
84          target.setIncludes(new String[] { "**/*.js" });
85          Assertions.assertFalse(target.getOutput().exists());
86          target.run(null, this.defaultBuildContext);
87          Assertions.assertFalse(target.getOutput().exists());
88      }
89  
90      /**
91       * Test 1 to 1.
92       *
93       * @throws IOException
94       *             the IO exception
95       */
96      @Test
97      void test1to1() throws IOException {
98          final var f1 = this.dir.toPath().resolve("01.js").toFile();
99          Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
100         final var target = new Aggregation();
101         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
102         target.setIncludes(new String[] { f1.getName() });
103 
104         Assertions.assertFalse(target.getOutput().exists());
105         target.run(null, this.defaultBuildContext);
106         Assertions.assertTrue(target.getOutput().exists());
107         Assertions.assertEquals(new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8),
108                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
109     }
110 
111     /**
112      * Test 2 to 1.
113      *
114      * @throws IOException
115      *             the IO exception
116      */
117     @Test
118     void test2to1() throws IOException {
119         final var f1 = this.dir.toPath().resolve("01.js").toFile();
120         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
121 
122         final var f2 = this.dir.toPath().resolve("02.js").toFile();
123         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
124 
125         final var target = new Aggregation();
126         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
127 
128         target.setIncludes(new String[] { f1.getName(), f2.getName() });
129         Assertions.assertFalse(target.getOutput().exists());
130         target.run(null, this.defaultBuildContext);
131         Assertions.assertTrue(target.getOutput().exists());
132         Assertions.assertEquals(
133                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
134                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
135                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
136 
137         target.getOutput().delete();
138         target.setIncludes(new String[] { "*.js" });
139         Assertions.assertFalse(target.getOutput().exists());
140         target.run(null, this.defaultBuildContext);
141         Assertions.assertTrue(target.getOutput().exists());
142         Assertions.assertEquals(
143                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
144                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
145                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
146     }
147 
148     /**
149      * Test no duplicate aggregation.
150      *
151      * @throws IOException
152      *             the IO exception
153      */
154     @Test
155     void testNoDuplicateAggregation() throws IOException {
156         final var f1 = this.dir.toPath().resolve("01.js").toFile();
157         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
158 
159         final var f2 = this.dir.toPath().resolve("02.js").toFile();
160         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
161 
162         final var target = new Aggregation();
163         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
164 
165         target.setIncludes(new String[] { f1.getName(), f1.getName(), f2.getName() });
166         Assertions.assertFalse(target.getOutput().exists());
167         target.run(null, this.defaultBuildContext);
168         Assertions.assertTrue(target.getOutput().exists());
169         Assertions.assertEquals(
170                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
171                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
172                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
173 
174         target.getOutput().delete();
175         target.setIncludes(new String[] { f1.getName(), "*.js" });
176         Assertions.assertFalse(target.getOutput().exists());
177         target.run(null, this.defaultBuildContext);
178         Assertions.assertTrue(target.getOutput().exists());
179         Assertions.assertEquals(
180                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
181                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
182                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
183     }
184 
185     /**
186      * Test 2 to 1 order.
187      *
188      * @throws IOException
189      *             the IO exception
190      */
191     @Test
192     void test2to1Order() throws IOException {
193         final var f1 = this.dir.toPath().resolve("01.js").toFile();
194         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
195 
196         final var f2 = this.dir.toPath().resolve("02.js").toFile();
197         Files.write(f2.toPath(), "2".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
198 
199         final var target = new Aggregation();
200         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
201 
202         target.setIncludes(new String[] { f2.getName(), f1.getName() });
203         Assertions.assertFalse(target.getOutput().exists());
204         target.run(null, this.defaultBuildContext);
205         Assertions.assertTrue(target.getOutput().exists());
206         Assertions.assertEquals(
207                 new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8)
208                         + new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8),
209                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
210     }
211 
212     /**
213      * Test 2 to 1 with new line.
214      *
215      * @throws IOException
216      *             the IO exception
217      */
218     @Test
219     void test2to1WithNewLine() throws IOException {
220         final var f1 = this.dir.toPath().resolve("01.js").toFile();
221         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
222 
223         final var f2 = this.dir.toPath().resolve("02.js").toFile();
224         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
225 
226         final var target = new Aggregation();
227         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
228         target.setInsertNewLine(true);
229         target.setIncludes(new String[] { f1.getName(), f2.getName() });
230 
231         Assertions.assertFalse(target.getOutput().exists());
232         target.run(null, this.defaultBuildContext);
233         Assertions.assertTrue(target.getOutput().exists());
234         Assertions.assertEquals(
235                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8) + "\n"
236                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8) + "\n",
237                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
238     }
239 
240     /**
241      * Test absolute path from inside.
242      *
243      * @throws IOException
244      *             the IO exception
245      */
246     @Test
247     void testAbsolutePathFromInside() throws IOException {
248         final var f1 = this.dir.toPath().resolve("01.js").toFile();
249         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
250 
251         final var f2 = this.dir.toPath().resolve("02.js").toFile();
252         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
253 
254         final var target = new Aggregation();
255         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
256 
257         target.setIncludes(new String[] { f1.getAbsolutePath(), f2.getName() });
258         Assertions.assertFalse(target.getOutput().exists());
259         target.run(null, this.defaultBuildContext);
260         Assertions.assertTrue(target.getOutput().exists());
261         Assertions.assertEquals(
262                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
263                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
264                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
265     }
266 
267     /**
268      * Test absolute path from outside.
269      *
270      * @throws IOException
271      *             the IO exception
272      */
273     @Test
274     void testAbsolutePathFromOutside() throws IOException {
275         final var f1 = File.createTempFile("test-01", ".js");
276         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
277 
278         final var f2 = this.dir.toPath().resolve("02.js").toFile();
279         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
280 
281         final var target = new Aggregation();
282         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
283 
284         try {
285             target.setIncludes(new String[] { f1.getAbsolutePath(), f2.getName() });
286             Assertions.assertFalse(target.getOutput().exists());
287             target.run(null, this.defaultBuildContext);
288             Assertions.assertTrue(target.getOutput().exists());
289             Assertions.assertEquals(
290                     new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
291                             + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
292                     new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
293         } finally {
294             f1.delete();
295         }
296     }
297 
298     /**
299      * Test auto exclude wildcards.
300      *
301      * @throws IOException
302      *             the IO exception
303      */
304     @Test
305     void testAutoExcludeWildcards() throws IOException {
306         final var f1 = this.dir.toPath().resolve("01.js").toFile();
307         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
308 
309         final var f2 = this.dir.toPath().resolve("02.js").toFile();
310         Files.write(f2.toPath(), "22\n22".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
311 
312         final var target = new Aggregation();
313         target.setAutoExcludeWildcards(true);
314         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
315 
316         final Collection<File> previouslyIncluded = new HashSet<>();
317         previouslyIncluded.add(f1.getCanonicalFile());
318 
319         target.setIncludes(new String[] { f1.getName(), f2.getName() });
320         Assertions.assertFalse(target.getOutput().exists());
321         // First call uses path that does not deal with previouslyIncluded so both files are added
322         final var content = target.run(previouslyIncluded, this.defaultBuildContext);
323         Assertions.assertEquals(2, content.size());
324         Assertions.assertTrue(target.getOutput().exists());
325         Assertions.assertEquals(
326                 new String(Files.readAllBytes(f1.toPath()), StandardCharsets.UTF_8)
327                         + new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
328                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
329 
330         target.getOutput().delete();
331         target.setIncludes(new String[] { "*.js" });
332         Assertions.assertFalse(target.getOutput().exists());
333         // f1 was in previouslyIncluded so it is not included
334         Assertions.assertEquals(target.run(previouslyIncluded, this.defaultBuildContext),
335                 Arrays.asList(f2.getCanonicalFile()));
336         Assertions.assertTrue(target.getOutput().exists());
337         Assertions.assertEquals(new String(Files.readAllBytes(f2.toPath()), StandardCharsets.UTF_8),
338                 new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8));
339     }
340 
341     /**
342      * Test that an insert file header is prepended to each included file's content.
343      *
344      * @throws IOException
345      *             the IO exception
346      */
347     @Test
348     void testInsertFileHeader() throws IOException {
349         final var f1 = this.dir.toPath().resolve("01.js").toFile();
350         Files.write(f1.toPath(), "content1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
351 
352         final var target = new Aggregation();
353         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
354         target.setIncludes(new String[] { f1.getName() });
355         setField(target, "insertFileHeader", true);
356 
357         target.run(null, this.defaultBuildContext);
358 
359         final var result = new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8);
360         Assertions.assertTrue(result.startsWith("/*01.js*/"), "Expected file header '/*01.js*/' but was: " + result);
361         Assertions.assertTrue(result.contains("content1"), "Expected file content after header");
362     }
363 
364     /**
365      * Test that file header is followed by a newline when insertNewLine is also enabled.
366      *
367      * @throws IOException
368      *             the IO exception
369      */
370     @Test
371     void testInsertFileHeaderWithNewLine() throws IOException {
372         final var f1 = this.dir.toPath().resolve("myfile.js").toFile();
373         Files.write(f1.toPath(), "abc".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
374 
375         final var target = new Aggregation();
376         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
377         target.setIncludes(new String[] { f1.getName() });
378         target.setInsertNewLine(true);
379         setField(target, "insertFileHeader", true);
380 
381         target.run(null, this.defaultBuildContext);
382 
383         final var result = new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8);
384         // Header should be "/*myfile.js*/\n" and then content, then "\n"
385         Assertions.assertTrue(result.startsWith("/*myfile.js*/\n"),
386                 "Expected header with trailing newline, but was: " + result);
387     }
388 
389     /**
390      * Test that fixLastSemicolon appends a semicolon after each file's content.
391      *
392      * @throws IOException
393      *             the IO exception
394      */
395     @Test
396     void testFixLastSemicolon() throws IOException {
397         final var f1 = this.dir.toPath().resolve("01.js").toFile();
398         Files.write(f1.toPath(), "var a=1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
399 
400         final var f2 = this.dir.toPath().resolve("02.js").toFile();
401         Files.write(f2.toPath(), "var b=2".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
402 
403         final var target = new Aggregation();
404         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
405         target.setIncludes(new String[] { f1.getName(), f2.getName() });
406         setField(target, "fixLastSemicolon", true);
407 
408         target.run(null, this.defaultBuildContext);
409 
410         final var result = new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8);
411         // Each file's content gets a semicolon appended
412         Assertions.assertEquals("var a=1;var b=2;", result);
413     }
414 
415     /**
416      * Test that removeIncluded deletes each source file after aggregation.
417      *
418      * @throws IOException
419      *             the IO exception
420      */
421     @Test
422     void testRemoveIncluded() throws IOException {
423         final var f1 = this.dir.toPath().resolve("01.js").toFile();
424         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
425 
426         final var f2 = this.dir.toPath().resolve("02.js").toFile();
427         Files.write(f2.toPath(), "2".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
428 
429         final var target = new Aggregation();
430         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
431         target.setIncludes(new String[] { f1.getName(), f2.getName() });
432         setField(target, "removeIncluded", true);
433 
434         target.run(null, this.defaultBuildContext);
435 
436         Assertions.assertFalse(f1.exists(), "f1 should be removed after aggregation");
437         Assertions.assertFalse(f2.exists(), "f2 should be removed after aggregation");
438         Assertions.assertTrue(target.getOutput().exists(), "Output file should still exist");
439     }
440 
441     /**
442      * Test that defineInputDir throws IllegalStateException when the output parent is not a directory.
443      *
444      * @throws IOException
445      *             the IO exception
446      */
447     @Test
448     void testInvalidInputDirectory_throwsIllegalStateException() throws IOException {
449         final var notADir = File.createTempFile("notadir", ".tmp");
450         notADir.deleteOnExit();
451 
452         final var target = new Aggregation();
453         // Set inputDir to a plain file (not a directory) via reflection
454         setField(target, "inputDir", notADir);
455         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
456         target.setIncludes(new String[] { "*.js" });
457 
458         Assertions.assertThrows(IllegalStateException.class, () -> target.run(null, this.defaultBuildContext));
459     }
460 
461     /**
462      * Test incremental build: when isIncremental returns true and the aggregated file is among the changed files, the
463      * aggregation is performed.
464      *
465      * @throws IOException
466      *             the IO exception
467      */
468     @Test
469     void testIncrementalBuildWithDelta() throws IOException {
470         final var f1 = this.dir.toPath().resolve("01.js").toFile();
471         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
472 
473         // Configure the legacy context to report incremental build
474         Mockito.when(this.legacyBuildContext.isIncremental()).thenReturn(true);
475 
476         final var target = new Aggregation();
477         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
478         target.setIncludes(new String[] { f1.getName() });
479 
480         final Set<String> incrementalFiles = new HashSet<>();
481         incrementalFiles.add(f1.getCanonicalPath());
482 
483         final var result = target.run(null, this.defaultBuildContext, incrementalFiles);
484         Assertions.assertEquals(1, result.size(), "Expected one file to be aggregated");
485         Assertions.assertTrue(target.getOutput().exists(), "Output should exist after aggregation with delta");
486     }
487 
488     /**
489      * Test incremental build: when isIncremental returns true but none of the included files changed, the aggregation
490      * is skipped.
491      *
492      * @throws IOException
493      *             the IO exception
494      */
495     @Test
496     void testIncrementalBuildNoDelta() throws IOException {
497         final var f1 = this.dir.toPath().resolve("01.js").toFile();
498         Files.write(f1.toPath(), "1".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
499 
500         // Configure the legacy context to report incremental build
501         Mockito.when(this.legacyBuildContext.isIncremental()).thenReturn(true);
502 
503         final var target = new Aggregation();
504         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
505         target.setIncludes(new String[] { f1.getName() });
506 
507         // No files in the incremental delta set
508         final Set<String> incrementalFiles = new HashSet<>();
509 
510         final var result = target.run(null, this.defaultBuildContext, incrementalFiles);
511         Assertions.assertTrue(result.isEmpty(), "Expected no files to be aggregated when no delta");
512         Assertions.assertFalse(target.getOutput().exists(), "Output should not be created when no delta");
513     }
514 
515     /**
516      * Test excludes: files matching the exclude pattern should not be included in the aggregation.
517      *
518      * @throws IOException
519      *             the IO exception
520      */
521     @Test
522     void testExcludes_wildcardIncludeWithExclude() throws IOException {
523         final var f1 = this.dir.toPath().resolve("include.js").toFile();
524         Files.write(f1.toPath(), "include".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
525 
526         final var f2 = this.dir.toPath().resolve("exclude.js").toFile();
527         Files.write(f2.toPath(), "exclude".getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE);
528 
529         final var target = new Aggregation();
530         target.setOutput(this.dir.toPath().resolve("output.js").toFile());
531         target.setIncludes(new String[] { "*.js" });
532         setField(target, "excludes", new String[] { "exclude.js" });
533 
534         target.run(null, this.defaultBuildContext);
535 
536         Assertions.assertTrue(target.getOutput().exists());
537         final var result = new String(Files.readAllBytes(target.getOutput().toPath()), StandardCharsets.UTF_8);
538         Assertions.assertEquals("include", result, "Excluded file content should not appear in output");
539     }
540 
541     /**
542      * Helper method to set a private/protected field value via reflection.
543      *
544      * @param target
545      *            the object to modify
546      * @param fieldName
547      *            the name of the field
548      * @param value
549      *            the value to set
550      */
551     private static void setField(Object target, String fieldName, Object value) {
552         try {
553             final Field field = target.getClass().getDeclaredField(fieldName);
554             field.setAccessible(true);
555             field.set(target, value);
556         } catch (NoSuchFieldException | IllegalAccessException e) {
557             throw new RuntimeException("Cannot set field '" + fieldName + "'", e);
558         }
559     }
560 }