matchers = new ArrayList<>();
+ for (String pattern : patterns) {
+ matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + pattern));
+ }
+ return matchers;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/ConverterPathResolver.java b/src/main/java/nablarch/test/tool/converter/ConverterPathResolver.java
new file mode 100644
index 00000000..4d4c166f
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/ConverterPathResolver.java
@@ -0,0 +1,55 @@
+package nablarch.test.tool.converter;
+
+import java.nio.file.Path;
+
+/**
+ * 入力パスと出力パスの対応関係を計算するユーティリティクラス。
+ */
+public final class ConverterPathResolver {
+
+ private ConverterPathResolver() {
+ }
+
+ /**
+ * XLS ファイルパスから YAML 出力ディレクトリパスを計算する。
+ *
+ * 例: inputRoot=src, xls=src/foo/FooTest.xls, outputRoot=out → out/foo/FooTest
+ *
+ * @param inputRoot 入力ルートディレクトリ
+ * @param xlsFile XLS ファイルパス
+ * @param outputRoot 出力ルートディレクトリ
+ * @return YAML ディレクトリパス
+ */
+ public static Path xlsToYamlDir(Path inputRoot, Path xlsFile, Path outputRoot) {
+ Path relative = inputRoot.relativize(xlsFile);
+ String fileName = relative.getFileName().toString();
+ // strip .xls extension
+ String baseName = fileName.endsWith(".xls")
+ ? fileName.substring(0, fileName.length() - 4) : fileName;
+ Path parent = relative.getParent();
+ if (parent != null) {
+ return outputRoot.resolve(parent).resolve(baseName);
+ }
+ return outputRoot.resolve(baseName);
+ }
+
+ /**
+ * YAML ディレクトリパスから XLS 出力ファイルパスを計算する。
+ *
+ * 例: inputRoot=src, yamlDir=src/foo/FooTest, outputRoot=out → out/foo/FooTest.xls
+ *
+ * @param inputRoot 入力ルートディレクトリ
+ * @param yamlDir YAML ディレクトリパス
+ * @param outputRoot 出力ルートディレクトリ
+ * @return XLS ファイルパス
+ */
+ public static Path yamlDirToXls(Path inputRoot, Path yamlDir, Path outputRoot) {
+ Path relative = inputRoot.relativize(yamlDir);
+ String dirName = relative.getFileName().toString();
+ Path parent = relative.getParent();
+ if (parent != null) {
+ return outputRoot.resolve(parent).resolve(dirName + ".xls");
+ }
+ return outputRoot.resolve(dirName + ".xls");
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/TestDataConverter.java b/src/main/java/nablarch/test/tool/converter/TestDataConverter.java
new file mode 100644
index 00000000..67e20617
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/TestDataConverter.java
@@ -0,0 +1,228 @@
+package nablarch.test.tool.converter;
+
+import nablarch.test.tool.converter.model.TestDataContainer;
+import nablarch.test.tool.converter.model.TestDataSection;
+import nablarch.test.tool.converter.xls.XlsFormatReader;
+import nablarch.test.tool.converter.xls.XlsFormatWriter;
+import nablarch.test.tool.converter.yaml.YamlFormatReader;
+import nablarch.test.tool.converter.yaml.YamlFormatWriter;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * テストデータ変換ツールのエントリポイント。
+ *
+ * 使用方法: TestDataConverter --from <形式> --to <形式> [options] <入力パス> <出力パス>
+ */
+public class TestDataConverter {
+
+ public static void main(String[] args) {
+ run(args);
+ }
+
+ /**
+ * 変換処理を実行する。テストからはこのメソッドを直接呼び出して終了コードを検証する。
+ *
+ * @param args コマンドライン引数
+ * @return 終了コード(0: 正常, 1: 変換エラーあり, 2: 引数エラー)
+ */
+ public static int run(String[] args) {
+ Options opts = parseArgs(args);
+ if (opts == null) {
+ System.err.println("Usage: TestDataConverter --from --to [--overwrite] [--delete-source] [--include ]... [--exclude ]... ");
+ return 2;
+ }
+ if (opts.from.equals(opts.to)) {
+ System.err.println("--from and --to must be different formats.");
+ return 2;
+ }
+
+ if (!opts.from.equals("xls") && !opts.from.equals("yaml")) {
+ System.err.println("Invalid --from value: " + opts.from + ". Must be 'xls' or 'yaml'.");
+ System.err.println("Usage: TestDataConverter --from --to [--overwrite] [--delete-source] [--include ]... [--exclude ]... ");
+ return 2;
+ }
+ if (!opts.to.equals("xls") && !opts.to.equals("yaml")) {
+ System.err.println("Invalid --to value: " + opts.to + ". Must be 'xls' or 'yaml'.");
+ System.err.println("Usage: TestDataConverter --from --to [--overwrite] [--delete-source] [--include ]... [--exclude ]... ");
+ return 2;
+ }
+
+ XlsFormatReader xlsReader = opts.from.equals("xls") ? new XlsFormatReader() : null;
+ TestDataFormatReader reader;
+ TestDataFormatWriter writer;
+ if (opts.from.equals("xls")) {
+ reader = xlsReader;
+ writer = new YamlFormatWriter();
+ } else {
+ reader = new YamlFormatReader();
+ writer = new XlsFormatWriter();
+ }
+
+ int[] skipCount = {0};
+ List targets;
+ try {
+ if (opts.from.equals("xls")) {
+ targets = ConverterFileFilter.findXlsFiles(opts.inputPath, opts.includes, opts.excludes, skipCount);
+ } else {
+ targets = ConverterFileFilter.findYamlDirs(opts.inputPath, opts.includes, opts.excludes, skipCount);
+ }
+ } catch (ConverterException e) {
+ System.err.println("ERROR: " + e.getMessage());
+ return 1;
+ }
+
+ int errorCount = 0;
+ int successCount = 0;
+ int totalCommentLines = 0;
+ int commentLineFiles = 0;
+
+ for (Path target : targets) {
+ try {
+ TestDataContainer container = reader.read(target);
+
+ if (xlsReader != null) {
+ int commentLines = xlsReader.getLastCommentLineCount();
+ if (commentLines > 0) {
+ totalCommentLines += commentLines;
+ commentLineFiles++;
+ }
+ }
+
+ // Warn and skip if no blocks in any section (NG-5)
+ boolean hasAnyBlock = false;
+ for (TestDataSection section : container.getSections()) {
+ if (!section.getBlocks().isEmpty()) {
+ hasAnyBlock = true;
+ break;
+ }
+ }
+ if (!hasAnyBlock && !container.getSections().isEmpty()) {
+ System.err.println("WARN: " + target + ": no data blocks found (empty sheet or comment-only). Skipping output.");
+ successCount++;
+ if (opts.deleteSource) {
+ deleteSource(target);
+ }
+ continue;
+ }
+
+ // Calculate output path
+ Path outputBase;
+ if (opts.from.equals("xls")) {
+ // For YamlFormatWriter, outputPath is the parent of containerName dir
+ outputBase = ConverterPathResolver.xlsToYamlDir(opts.inputPath, target, opts.outputPath).getParent();
+ if (outputBase == null) outputBase = opts.outputPath;
+ } else {
+ // For XlsFormatWriter, outputPath is the parent of containerName.xls
+ outputBase = ConverterPathResolver.yamlDirToXls(opts.inputPath, target, opts.outputPath).getParent();
+ if (outputBase == null) outputBase = opts.outputPath;
+ }
+
+ writer.write(container, outputBase, opts.overwrite);
+
+ if (opts.deleteSource) {
+ deleteSource(target);
+ }
+ successCount++;
+ } catch (ConverterException e) {
+ System.err.println("ERROR: " + target + ": " + e.getMessage());
+ errorCount++;
+ }
+ }
+
+ System.out.println("=== TestDataConverter 変換サマリー ===");
+ System.out.println("変換成功: " + successCount + " 件");
+ if (skipCount[0] > 0) {
+ System.out.println("スキップ: " + skipCount[0] + " 件(除外パターン合致)");
+ }
+ System.out.println("エラー: " + errorCount + " 件");
+ if (totalCommentLines > 0) {
+ System.out.println("コメント行ロスト: " + totalCommentLines + " 行(" + commentLineFiles + " ファイル)");
+ }
+ return errorCount > 0 ? 1 : 0;
+ }
+
+ private static void deleteSource(Path target) {
+ File f = target.toFile();
+ if (f.isFile()) {
+ if (!f.delete()) {
+ System.err.println("WARN: Failed to delete source: " + f);
+ }
+ } else {
+ deleteDirectory(f);
+ }
+ }
+
+ private static void deleteDirectory(File dir) {
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ if (f.isDirectory()) deleteDirectory(f);
+ else if (!f.delete()) {
+ System.err.println("WARN: Failed to delete source file: " + f);
+ }
+ }
+ }
+ if (!dir.delete()) {
+ System.err.println("WARN: Failed to delete source directory: " + dir);
+ }
+ }
+
+ private static Options parseArgs(String[] args) {
+ Options opts = new Options();
+ List positional = new ArrayList<>();
+ int i = 0;
+ while (i < args.length) {
+ String arg = args[i];
+ switch (arg) {
+ case "--from":
+ if (++i >= args.length) return null;
+ opts.from = args[i];
+ break;
+ case "--to":
+ if (++i >= args.length) return null;
+ opts.to = args[i];
+ break;
+ case "--overwrite":
+ opts.overwrite = true;
+ break;
+ case "--delete-source":
+ opts.deleteSource = true;
+ break;
+ case "--include":
+ if (++i >= args.length) return null;
+ opts.includes.add(args[i]);
+ break;
+ case "--exclude":
+ if (++i >= args.length) return null;
+ opts.excludes.add(args[i]);
+ break;
+ default:
+ positional.add(arg);
+ break;
+ }
+ i++;
+ }
+ if (opts.from == null || opts.to == null || positional.size() < 2) {
+ return null;
+ }
+ opts.inputPath = Paths.get(positional.get(positional.size() - 2));
+ opts.outputPath = Paths.get(positional.get(positional.size() - 1));
+ return opts;
+ }
+
+ private static class Options {
+ String from;
+ String to;
+ boolean overwrite = false;
+ boolean deleteSource = false;
+ List includes = new ArrayList<>();
+ List excludes = new ArrayList<>();
+ Path inputPath;
+ Path outputPath;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/TestDataFormatReader.java b/src/main/java/nablarch/test/tool/converter/TestDataFormatReader.java
new file mode 100644
index 00000000..cef0a455
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/TestDataFormatReader.java
@@ -0,0 +1,20 @@
+package nablarch.test.tool.converter;
+
+import nablarch.test.tool.converter.model.TestDataContainer;
+
+import java.nio.file.Path;
+
+/**
+ * テストデータを読み込んで {@link TestDataContainer} に変換するインターフェース。
+ */
+public interface TestDataFormatReader {
+
+ /**
+ * 指定されたパスを読み込み、TestDataContainer として返す。
+ *
+ * @param sourcePath 読み込み元パス(Excel ファイル / YAML ディレクトリ)
+ * @return 変換結果の TestDataContainer
+ * @throws ConverterException IO エラーまたは書式エラーが発生した場合
+ */
+ TestDataContainer read(Path sourcePath) throws ConverterException;
+}
diff --git a/src/main/java/nablarch/test/tool/converter/TestDataFormatWriter.java b/src/main/java/nablarch/test/tool/converter/TestDataFormatWriter.java
new file mode 100644
index 00000000..a5162cb2
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/TestDataFormatWriter.java
@@ -0,0 +1,21 @@
+package nablarch.test.tool.converter;
+
+import nablarch.test.tool.converter.model.TestDataContainer;
+
+import java.nio.file.Path;
+
+/**
+ * {@link TestDataContainer} を指定された形式で書き出すインターフェース。
+ */
+public interface TestDataFormatWriter {
+
+ /**
+ * TestDataContainer を指定されたパスに書き出す。
+ *
+ * @param container 書き出す TestDataContainer
+ * @param outputPath 書き出し先の基底パス(Excel ファイル / YAML ディレクトリの親)
+ * @param overwrite 既存ファイルを上書きするか
+ * @throws ConverterException IO エラーまたは上書き禁止エラーが発生した場合
+ */
+ void write(TestDataContainer container, Path outputPath, boolean overwrite) throws ConverterException;
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/ColumnRowDataBlock.java b/src/main/java/nablarch/test/tool/converter/model/ColumnRowDataBlock.java
new file mode 100644
index 00000000..7299935c
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/ColumnRowDataBlock.java
@@ -0,0 +1,32 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+import java.util.List;
+
+/**
+ * テーブルデータ・LIST_MAP の共通基底クラス。
+ * カラム名リストとデータ行リストを保持する。
+ */
+public abstract sealed class ColumnRowDataBlock extends TestDataBlock permits TableDataBlock, ListMapBlock {
+
+ private final List columnNames;
+ private final List> rows;
+
+ protected ColumnRowDataBlock(DataType dataType, String groupId, String identifier,
+ List columnNames, List> rows) {
+ super(dataType, groupId, identifier);
+ this.columnNames = columnNames;
+ this.rows = rows;
+ }
+
+ /** カラム名リスト(マーカーカラムを含む)。 */
+ public List getColumnNames() {
+ return columnNames;
+ }
+
+ /** データ行のリスト(null・空文字を区別して保持)。 */
+ public List> getRows() {
+ return rows;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/FieldDef.java b/src/main/java/nablarch/test/tool/converter/model/FieldDef.java
new file mode 100644
index 00000000..dcc046d8
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/FieldDef.java
@@ -0,0 +1,35 @@
+package nablarch.test.tool.converter.model;
+
+/**
+ * ファイルデータブロックのフィールド定義。
+ * 不変オブジェクト。
+ */
+public final class FieldDef {
+
+ private final String name;
+ /** データ型記号("X", "N", "Z" 等)。可変長 FW_HEADER では null。 */
+ private final String type;
+ /**
+ * フィールド長。固定長のみ。可変長は null。
+ * "-"(SS-17: 自動拡張指示)を含むためリテラルとして String で保持する。
+ */
+ private final String length;
+
+ public FieldDef(String name, String type, String length) {
+ this.name = name;
+ this.type = type;
+ this.length = length;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getLength() {
+ return length;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/FileDataBlock.java b/src/main/java/nablarch/test/tool/converter/model/FileDataBlock.java
new file mode 100644
index 00000000..24f10369
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/FileDataBlock.java
@@ -0,0 +1,40 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * SETUP_FIXED / SETUP_VARIABLE / EXPECTED_FIXED / EXPECTED_VARIABLE のデータブロック。
+ */
+public final class FileDataBlock extends TestDataBlock {
+
+ /** ファイルデータブロックの種別。SETUP/EXPECTED を問わず固定長か可変長かを区別する。 */
+ public enum FileType { FIXED, VARIABLE }
+
+ private final FileType fileType;
+ /** ディレクティブ(キー → 値)。Excel の行順を保持するため LinkedHashMap を使用する。 */
+ private final Map directives;
+ private final List records;
+
+ public FileDataBlock(DataType dataType, String groupId, String identifier,
+ FileType fileType, Map directives, List records) {
+ super(dataType, groupId, identifier);
+ this.fileType = fileType;
+ this.directives = directives;
+ this.records = records;
+ }
+
+ public FileType getFileType() {
+ return fileType;
+ }
+
+ public Map getDirectives() {
+ return directives;
+ }
+
+ public List getRecords() {
+ return records;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/ListMapBlock.java b/src/main/java/nablarch/test/tool/converter/model/ListMapBlock.java
new file mode 100644
index 00000000..0f9f744f
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/ListMapBlock.java
@@ -0,0 +1,16 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+import java.util.List;
+
+/**
+ * LIST_MAP のデータブロック。
+ */
+public final class ListMapBlock extends ColumnRowDataBlock {
+
+ public ListMapBlock(String groupId, String identifier,
+ List columnNames, List> rows) {
+ super(DataType.LIST_MAP, groupId, identifier, columnNames, rows);
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/MessageDataBlock.java b/src/main/java/nablarch/test/tool/converter/model/MessageDataBlock.java
new file mode 100644
index 00000000..e70d6365
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/MessageDataBlock.java
@@ -0,0 +1,32 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * MESSAGE / EXPECTED_REQUEST_*_MESSAGES / RESPONSE_*_MESSAGES のデータブロック。
+ */
+public final class MessageDataBlock extends TestDataBlock {
+
+ /** FW 制御ヘッダフィールド(FW_HEADER レコード)。Excel の行順を保持するため LinkedHashMap を使用する。 */
+ private final Map fwHeaderFields;
+ /** レコードレイアウトのリスト(FieldDef は name のみ)。 */
+ private final List records;
+
+ public MessageDataBlock(DataType dataType, String groupId, String identifier,
+ Map fwHeaderFields, List records) {
+ super(dataType, groupId, identifier);
+ this.fwHeaderFields = fwHeaderFields;
+ this.records = records;
+ }
+
+ public Map getFwHeaderFields() {
+ return fwHeaderFields;
+ }
+
+ public List getRecords() {
+ return records;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/RecordLayout.java b/src/main/java/nablarch/test/tool/converter/model/RecordLayout.java
new file mode 100644
index 00000000..013bb925
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/RecordLayout.java
@@ -0,0 +1,31 @@
+package nablarch.test.tool.converter.model;
+
+import java.util.List;
+
+/**
+ * ファイルデータブロック・メッセージングデータブロックのレコードレイアウト。
+ */
+public class RecordLayout {
+
+ private final String recordType;
+ private final List fields;
+ private final List> rows;
+
+ public RecordLayout(String recordType, List fields, List> rows) {
+ this.recordType = recordType;
+ this.fields = fields;
+ this.rows = rows;
+ }
+
+ public String getRecordType() {
+ return recordType;
+ }
+
+ public List getFields() {
+ return fields;
+ }
+
+ public List> getRows() {
+ return rows;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/TableDataBlock.java b/src/main/java/nablarch/test/tool/converter/model/TableDataBlock.java
new file mode 100644
index 00000000..c8a8d84a
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/TableDataBlock.java
@@ -0,0 +1,16 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+import java.util.List;
+
+/**
+ * SETUP_TABLE / EXPECTED_TABLE / EXPECTED_COMPLETE_TABLE のデータブロック。
+ */
+public final class TableDataBlock extends ColumnRowDataBlock {
+
+ public TableDataBlock(DataType dataType, String groupId, String identifier,
+ List columnNames, List> rows) {
+ super(dataType, groupId, identifier, columnNames, rows);
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/TestDataBlock.java b/src/main/java/nablarch/test/tool/converter/model/TestDataBlock.java
new file mode 100644
index 00000000..0e50044e
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/TestDataBlock.java
@@ -0,0 +1,32 @@
+package nablarch.test.tool.converter.model;
+
+import nablarch.test.core.reader.DataType;
+
+/**
+ * NTF の 1 データブロックに相当する抽象クラス。
+ */
+public abstract sealed class TestDataBlock permits ColumnRowDataBlock, FileDataBlock, MessageDataBlock {
+
+ private final DataType dataType;
+ private final String groupId;
+ private final String identifier;
+
+ protected TestDataBlock(DataType dataType, String groupId, String identifier) {
+ this.dataType = dataType;
+ this.groupId = groupId;
+ this.identifier = identifier;
+ }
+
+ public DataType getDataType() {
+ return dataType;
+ }
+
+ /** groupId(省略時は空文字)。 */
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/TestDataContainer.java b/src/main/java/nablarch/test/tool/converter/model/TestDataContainer.java
new file mode 100644
index 00000000..b72772ab
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/TestDataContainer.java
@@ -0,0 +1,26 @@
+package nablarch.test.tool.converter.model;
+
+import java.util.List;
+
+/**
+ * Excel ブック / YAML ディレクトリに相当するコンテナ。テストクラスと 1 対 1 に対応する。
+ */
+public class TestDataContainer {
+
+ private final String name;
+ private final List sections;
+
+ public TestDataContainer(String name, List sections) {
+ this.name = name;
+ this.sections = sections;
+ }
+
+ /** ブック名 / ディレクトリ名(拡張子なし)。 */
+ public String getName() {
+ return name;
+ }
+
+ public List getSections() {
+ return sections;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/model/TestDataSection.java b/src/main/java/nablarch/test/tool/converter/model/TestDataSection.java
new file mode 100644
index 00000000..15d81177
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/model/TestDataSection.java
@@ -0,0 +1,26 @@
+package nablarch.test.tool.converter.model;
+
+import java.util.List;
+
+/**
+ * Excel シート / YAML ファイル 1 枚に相当する。NTF の読み込み単位。
+ */
+public class TestDataSection {
+
+ private final String name;
+ private final List blocks;
+
+ public TestDataSection(String name, List blocks) {
+ this.name = name;
+ this.blocks = blocks;
+ }
+
+ /** シート名 / YAML ファイル名(拡張子なし)。 */
+ public String getName() {
+ return name;
+ }
+
+ public List getBlocks() {
+ return blocks;
+ }
+}
diff --git a/src/main/java/nablarch/test/tool/converter/xls/XlsFormatReader.java b/src/main/java/nablarch/test/tool/converter/xls/XlsFormatReader.java
new file mode 100644
index 00000000..47bc2f35
--- /dev/null
+++ b/src/main/java/nablarch/test/tool/converter/xls/XlsFormatReader.java
@@ -0,0 +1,428 @@
+package nablarch.test.tool.converter.xls;
+
+import nablarch.test.core.reader.DataType;
+import nablarch.test.tool.converter.ConverterException;
+import nablarch.test.tool.converter.TestDataFormatReader;
+import nablarch.test.tool.converter.model.ColumnRowDataBlock;
+import nablarch.test.tool.converter.model.FieldDef;
+import nablarch.test.tool.converter.model.FileDataBlock;
+import nablarch.test.tool.converter.model.ListMapBlock;
+import nablarch.test.tool.converter.model.MessageDataBlock;
+import nablarch.test.tool.converter.model.RecordLayout;
+import nablarch.test.tool.converter.model.TableDataBlock;
+import nablarch.test.tool.converter.model.TestDataBlock;
+import nablarch.test.tool.converter.model.TestDataContainer;
+import nablarch.test.tool.converter.model.TestDataSection;
+import org.apache.poi.hssf.usermodel.HSSFWorkbook;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * XLS ファイルを読み込んで {@link TestDataContainer} に変換する Reader。
+ */
+public class XlsFormatReader implements TestDataFormatReader {
+
+ /** 直前の read() 呼び出しで検出したコメント行数。 */
+ private int lastCommentLineCount = 0;
+
+ /** 直前の read() 呼び出しで検出したコメント行数を返す。 */
+ public int getLastCommentLineCount() {
+ return lastCommentLineCount;
+ }
+
+ @Override
+ public TestDataContainer read(Path sourcePath) throws ConverterException {
+ String fileName = sourcePath.getFileName().toString();
+ String name = fileName.endsWith(".xls") ? fileName.substring(0, fileName.length() - 4) : fileName;
+ lastCommentLineCount = 0;
+
+ try {
+ FileInputStream fis = new FileInputStream(sourcePath.toFile());
+ Workbook wb;
+ try {
+ wb = new HSSFWorkbook(fis);
+ } finally {
+ fis.close();
+ }
+ List sections = new ArrayList<>();
+ for (int i = 0; i < wb.getNumberOfSheets(); i++) {
+ Sheet sheet = wb.getSheetAt(i);
+ sections.add(parseSheet(sheet, sourcePath));
+ }
+ return new TestDataContainer(name, sections);
+ } catch (IOException e) {
+ throw new ConverterException("Failed to read XLS file: " + sourcePath, e);
+ }
+ }
+
+ private TestDataSection parseSheet(Sheet sheet, Path sourcePath) throws ConverterException {
+ List> rows = readRows(sheet, sourcePath);
+ List blocks = parseBlocks(rows);
+ return new TestDataSection(sheet.getSheetName(), blocks);
+ }
+
+ /** シートの全行を読み込み、コメント行スキップ・行内コメント切り捨て・空行スキップを適用する。 */
+ private List> readRows(Sheet sheet, Path sourcePath) {
+ List> result = new ArrayList<>();
+ int lastRow = sheet.getLastRowNum();
+ for (int r = 0; r <= lastRow; r++) {
+ Row row = sheet.getRow(r);
+ if (row == null) {
+ continue;
+ }
+ List cells = readCells(row, sourcePath, r + 1);
+ if (cells.isEmpty()) {
+ continue; // HC-07: 空行スキップ
+ }
+ if (cells.get(0).startsWith("//")) {
+ // HC-05: コメント行スキップ(警告出力・カウント)
+ lastCommentLineCount++;
+ System.err.println("WARN: " + sourcePath + " sheet=" + sheet.getSheetName()
+ + " row=" + (r + 1) + ": comment line skipped (HC-05)");
+ continue;
+ }
+ result.add(cells);
+ }
+ return result;
+ }
+
+ /** 1行のセルを読み込む。行内コメント(HC-06)を切り捨て、末尾の空セルは保持する。 */
+ private List readCells(Row row, Path sourcePath, int rowNum) {
+ int lastCell = row.getLastCellNum();
+ List cells = new ArrayList<>();
+ for (int c = 0; c < lastCell; c++) {
+ Cell cell = row.getCell(c);
+ String value;
+ if (cell == null) {
+ value = "";
+ } else {
+ // 数値書式・日付書式セルは警告出力(NG-4)
+ if (cell.getCellType() == Cell.CELL_TYPE_NUMERIC) {
+ System.err.println("WARN: " + sourcePath + " row=" + rowNum + " col=" + (c + 1)
+ + ": numeric/date cell detected. Cell.toString() result used.");
+ }
+ value = cell.toString();
+ }
+ if (c > 0 && value.startsWith("//")) {
+ // HC-06: 先頭以外のセルが "//" で始まる場合、そのセル以降を切り捨て
+ break;
+ }
+ cells.add(value);
+ }
+ return trimTrailingEmpty(cells);
+ }
+
+ /** 行リストを走査してデータブロックに分割する。 */
+ private List parseBlocks(List> rows) throws ConverterException {
+ List blocks = new ArrayList<>();
+ int i = 0;
+ while (i < rows.size()) {
+ List row = rows.get(i);
+ DataType dataType = detectDataType(row.get(0));
+ if (dataType == null) {
+ i++;
+ continue;
+ }
+ // 識別行の解析(DT-02, DT-06)
+ String[] parsed = parseIdentifierRow(row.get(0), dataType);
+ String groupId = parsed[0];
+ String identifier = parsed[1];
+
+ if (isColumnRowType(dataType)) {
+ // テーブルデータ・LIST_MAP の解析
+ int[] next = new int[1];
+ next[0] = i + 1;
+ TestDataBlock block = parseColumnRowBlock(dataType, groupId, identifier, rows, next);
+ blocks.add(block);
+ i = next[0];
+ } else if (isFileType(dataType)) {
+ int[] next = new int[1];
+ next[0] = i + 1;
+ TestDataBlock block = parseFileBlock(dataType, groupId, identifier, rows, next);
+ blocks.add(block);
+ i = next[0];
+ } else if (isMessageType(dataType)) {
+ int[] next = new int[1];
+ next[0] = i + 1;
+ TestDataBlock block = parseMessageBlock(dataType, groupId, identifier, rows, next);
+ blocks.add(block);
+ i = next[0];
+ } else {
+ throw new AssertionError("UNREACHABLE: unexpected DataType: " + dataType);
+ }
+ }
+ return blocks;
+ }
+
+ /** テーブルデータブロック・LIST_MAP ブロックの解析(SS-01, HC-01, HC-03, HC-04)。 */
+ private TestDataBlock parseColumnRowBlock(DataType dataType, String groupId, String identifier,
+ List> rows, int[] nextIndex) {
+ int i = nextIndex[0];
+ // ヘッダ行
+ List headerRow = i < rows.size() ? rows.get(i++) : new ArrayList<>();
+ List columnNames = trimTrailingEmpty(headerRow); // HC-03
+
+ // データ行
+ List> dataRows = new ArrayList<>();
+ while (i < rows.size()) {
+ List row = rows.get(i);
+ if (detectDataType(row.get(0)) != null) {
+ break;
+ }
+ // HC-04: データ行がヘッダより短い場合、空文字補完
+ List dataRow = new ArrayList<>(row);
+ while (dataRow.size() < columnNames.size()) {
+ dataRow.add("");
+ }
+ dataRows.add(new ArrayList<>(dataRow.subList(0, columnNames.size())));
+ i++;
+ }
+ nextIndex[0] = i;
+
+ if (dataType == DataType.LIST_MAP) {
+ return new ListMapBlock(groupId, identifier, columnNames, dataRows);
+ }
+ return new TableDataBlock(dataType, groupId, identifier, columnNames, dataRows);
+ }
+
+ /** ファイルデータブロックの解析(SS-08〜SS-13, SS-15, SS-17, DR-01, DR-07)。 */
+ private FileDataBlock parseFileBlock(DataType dataType, String groupId, String identifier,
+ List> rows, int[] nextIndex) {
+ Map directives = new LinkedHashMap<>();
+ List records = new ArrayList<>();
+ int i = nextIndex[0];
+
+ // ディレクティブ行の読み込み
+ // 判定ルール: 先頭セルが非空かつ次行が EOF または次行先頭も非空 → ディレクティブ行
+ // 先頭セルが空 → フィールド名行の開始(break)
+ // 次行先頭が空 → フィールド名行の開始(break)
+ while (i < rows.size()) {
+ List row = rows.get(i);
+ if (detectDataType(row.get(0)) != null) {
+ break;
+ }
+ if (row.get(0).isEmpty()) {
+ break; // フィールド名行(先頭が空)に到達
+ }
+ // 次行が存在し、かつ次行先頭セルが空 → 現在行はフィールド名行の直前(break前にディレクティブ登録はしない)
+ boolean nextExists = (i + 1 < rows.size()) && !rows.get(i + 1).isEmpty();
+ boolean nextFirstEmpty = nextExists && rows.get(i + 1).get(0).isEmpty();
+ if (nextFirstEmpty) {
+ // 次行はフィールド名行(先頭空) → ここで break してレコードレイアウト解析へ
+ break;
+ }
+ // ディレクティブ行として登録(次行が EOF / 次行先頭が非空 / 次行が新 DataType の場合も含む)
+ directives.put(row.get(0), row.size() > 1 ? row.get(1) : "");
+ i++;
+ }
+
+ // レコードレイアウトの解析
+ while (i < rows.size()) {
+ List row = rows.get(i);
+ if (detectDataType(row.get(0)) != null) {
+ break;
+ }
+ if (row.get(0).isEmpty()) {
+ break;
+ }
+ // フィールド名行
+ String recordType = row.get(0);
+ List fieldNames = row.subList(1, row.size());
+ fieldNames = trimTrailingEmpty(fieldNames);
+ i++;
+
+ // データ型行
+ List types = new ArrayList<>();
+ if (i < rows.size() && rows.get(i).get(0).isEmpty()) {
+ List typeRow = rows.get(i).subList(1, rows.get(i).size());
+ types = trimTrailingEmpty(typeRow);
+ i++;
+ }
+
+ // フィールド長行(固定長のみ)
+ List